Compare commits

..

2 commits

Author SHA1 Message Date
Your Name
464774ef4b Add structure-scoped notifications 2026-08-21 11:35:38 -04:00
Your Name
e7064a7c6a bliss-cli: detect failed logins instead of caching anon sessions
The server issues a connect.sid cookie on every response, including a
rejected login (which re-renders the form as 200). login() treated any
returned cookie as success, so bad creds silently cached an anonymous
session and the CLI stayed logged out with no warning — and since it only
re-authenticates on a 401 (which the server never sends), the stale cookie
was never refreshed.

Trust the redirect status instead (302 = success, 2xx = rejected) and throw
a clear error with the HTTP code on failure. Add a `bliss login` command
that forces a fresh login past any stale cookie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:17:28 -04:00
6 changed files with 483 additions and 4 deletions

View file

@ -68,6 +68,14 @@ const commands = {
out(c.BASE); out(c.BASE);
} }
}, },
// Force a fresh login with BLISS_USER / BLISS_PASS and report the result.
// Unlike normal commands (which reuse a cached cookie and only re-auth on a
// 401 the server never sends), this always hits /login, so it's the way to
// (re)authenticate after changing creds or clearing a stale anonymous cookie.
async login() {
await c.login(); // throws with a clear message if the server rejects creds
out("logged in as '" + (process.env.BLISS_USER || "?") + "' -> " + c.BASE);
},
// ---- reads (JSON via /plumbing) ---- // ---- reads (JSON via /plumbing) ----
async structures() { async structures() {
out(await c.getJSON("/plumbing/structures")); out(await c.getJSON("/plumbing/structures"));

View file

@ -79,10 +79,21 @@ async function login() {
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: USER, password: PASS }).toString(), body: new URLSearchParams({ username: USER, password: PASS }).toString(),
}); });
// The server issues an (anonymous) connect.sid on EVERY response — including a
// failed login, which re-renders the form as a 200. So the presence of a
// cookie says nothing about success. The real signal is the redirect: a good
// login 302s to `next`/"/", a rejected one stays 2xx. Trust the status, not
// the cookie, or we'd cache an anonymous session and silently stay logged out.
const cookie = extractCookie(res); const cookie = extractCookie(res);
if (!cookie) { const redirected = res.status >= 300 && res.status < 400;
// A 200 back from /login means the login form re-rendered => bad creds. if (!redirected || !cookie) {
throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)"); throw new Error(
"login failed for user '" +
USER +
"' — server rejected the credentials (HTTP " +
res.status +
"). Check BLISS_USER / BLISS_PASS.",
);
} }
saveCookie(cookie); saveCookie(cookie);
return cookie; return cookie;
@ -168,6 +179,7 @@ async function getText(urlPath) {
module.exports = { module.exports = {
BASE, BASE,
setTarget, setTarget,
login,
request, request,
getJSON, getJSON,
getText, getText,

99
db.js
View file

@ -296,6 +296,100 @@ function setStructurePublic(structureId, isPublic) {
); );
} }
// ---- kernel notifications -------------------------------------------------
//
// Subscriptions are owned by the platform, not by structure databases. Each row
// is tied to exactly one structure and one logged-in Bliss user; send-time code
// re-checks canAccessStructure before handing anything to a push service.
function upsertNotificationSubscription({
structureId,
userId,
endpoint,
clientId,
subscription,
contentEncoding,
}) {
return db
.prepare(
`INSERT INTO notification_subscriptions
(structure_id, user_id, endpoint, client_id, subscription, content_encoding, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(structure_id, endpoint) DO UPDATE SET
user_id = excluded.user_id,
client_id = excluded.client_id,
subscription = excluded.subscription,
content_encoding = excluded.content_encoding,
updated_at = CURRENT_TIMESTAMP`,
)
.run(
structureId,
userId,
endpoint,
clientId == null ? null : String(clientId),
JSON.stringify(subscription),
contentEncoding === "aesgcm" ? "aesgcm" : "aes128gcm",
);
}
function deleteNotificationSubscription(structureId, endpoint) {
return db
.prepare(
`DELETE FROM notification_subscriptions
WHERE structure_id = ? AND endpoint = ?`,
)
.run(structureId, endpoint);
}
function getNotificationSubscriptions(structureId, userIds = null) {
if (userIds && userIds.length === 0) return [];
const rows = userIds
? db
.prepare(
`SELECT *
FROM notification_subscriptions
WHERE structure_id = ?
AND user_id IN (${userIds.map(() => "?").join(",")})`,
)
.all(structureId, ...userIds)
: db
.prepare(
`SELECT *
FROM notification_subscriptions
WHERE structure_id = ?`,
)
.all(structureId);
return rows.map((row) => ({
...row,
subscription: JSON.parse(row.subscription),
}));
}
function getSubscribedNotificationUserIds(structureId) {
return db
.prepare(
`SELECT DISTINCT user_id
FROM notification_subscriptions
WHERE structure_id = ?
ORDER BY user_id`,
)
.all(structureId)
.map((row) => row.user_id);
}
function getStructureMemberUserIds(structureId) {
return db
.prepare(
`SELECT user_id
FROM structure_members
WHERE structure_id = ?
ORDER BY user_id`,
)
.all(structureId)
.map((row) => row.user_id);
}
function createRoute(verb, path, structureId, handler) { function createRoute(verb, path, structureId, handler) {
path = encodeURI(path); path = encodeURI(path);
@ -890,6 +984,11 @@ module.exports = {
removeMember, removeMember,
setStructurePrivacy, setStructurePrivacy,
setStructurePublic, setStructurePublic,
upsertNotificationSubscription,
deleteNotificationSubscription,
getNotificationSubscriptions,
getSubscribedNotificationUserIds,
getStructureMemberUserIds,
createRoute, createRoute,
getRoutes, getRoutes,
getRoute, getRoute,

212
index.js
View file

@ -210,6 +210,7 @@ function makeLibs(structureId, console, memberUserId) {
return dbs[alias]; return dbs[alias];
}, },
push: push, push: push,
notifications: makeNotifications(structureId, memberUserId),
files: { saveFile: (...args) => saveFile(structureId, ...args) }, files: { saveFile: (...args) => saveFile(structureId, ...args) },
}; };
} }
@ -228,6 +229,126 @@ function currentUserFor(req) {
} }
} }
function userIdFrom(value) {
if (value == null) return null;
if (typeof value === "object" && value.id != null) return Number(value.id);
const n = Number(value);
return Number.isInteger(n) ? n : null;
}
function normalizeNotificationUrl(structure, rawUrl) {
const route = makeRoute(structure);
const url = rawUrl == null || rawUrl === "" ? "/" : String(rawUrl);
if (!url.startsWith("/") || url.startsWith("//")) {
throw new Error("notification url must be a same-site path");
}
const prefix = structure.route_prefix || "";
const scoped = prefix && !url.startsWith(prefix) ? route(url) : url;
if (prefix && scoped !== prefix && !scoped.startsWith(prefix + "/")) {
throw new Error("notification url must stay inside this structure");
}
return scoped;
}
function normalizeNotificationRecipients(structureId, target) {
const explicit = target !== "viewers" && target !== "subscribers";
let ids;
if (target == null || target === "viewers" || target === "subscribers") {
ids = model.getSubscribedNotificationUserIds(structureId);
} else if (target === "members") {
ids = model.getStructureMemberUserIds(structureId);
} else if (Array.isArray(target)) {
ids = target.map(userIdFrom);
} else {
ids = [userIdFrom(target)];
}
if (ids.some((id) => !Number.isInteger(id))) {
throw new Error("notification recipient must be a Bliss user");
}
const unique = [...new Set(ids)];
const forbidden = unique.filter(
(id) => !model.canAccessStructure(id, structureId),
);
if (forbidden.length && explicit) {
throw new Error("notification recipient cannot access this structure");
}
return unique.filter((id) => !forbidden.includes(id));
}
function normalizeNotificationExclusions(options = {}) {
const ids = [];
const collect = (value) => {
if (Array.isArray(value)) return value.forEach(collect);
const id = userIdFrom(value);
if (id != null) ids.push(id);
};
collect(options.except);
collect(options.exceptUser);
collect(options.exceptUserId);
return {
userIds: new Set(ids),
clientId:
options.exceptClientId == null ? null : String(options.exceptClientId),
};
}
function makeNotifications(structureId, actorUserId) {
const structure = model.getStructure(structureId);
return {
async send(options = {}) {
const target = options.to ?? options.users ?? options.user ?? "viewers";
const recipientIds = normalizeNotificationRecipients(structureId, target);
const except = normalizeNotificationExclusions(options);
const eligibleIds = recipientIds.filter((id) => !except.userIds.has(id));
const url = normalizeNotificationUrl(structure, options.url || "/");
const rows = model.getNotificationSubscriptions(structureId, eligibleIds);
const payload = JSON.stringify({
structureId: String(structureId),
title: String(options.title || structure.name || "Bliss"),
body: String(options.body || ""),
url,
});
let sent = 0;
let skipped = 0;
await Promise.all(
rows.map(async (row) => {
if (except.clientId && row.client_id === except.clientId) {
skipped += 1;
return;
}
if (!model.canAccessStructure(row.user_id, structureId)) {
skipped += 1;
return;
}
try {
await push.sendNotification(row.subscription, payload, {
contentEncoding: row.content_encoding || undefined,
});
sent += 1;
} catch (err) {
if (err && (err.statusCode === 404 || err.statusCode === 410)) {
model.deleteNotificationSubscription(structureId, row.endpoint);
} else {
throw err;
}
}
}),
);
return {
sent,
skipped,
structureId,
actorUserId: actorUserId ?? null,
};
},
};
}
function bootstrapContext(structureId, routeId, initContext, memberUserId) { function bootstrapContext(structureId, routeId, initContext, memberUserId) {
const structure = model.getStructure(structureId); const structure = model.getStructure(structureId);
const console = makeConsole(structureId, routeId); const console = makeConsole(structureId, routeId);
@ -363,6 +484,10 @@ wsRouter.ws("*", (ws, req) => {
}), }),
); );
}; };
req.user = req.currentUser;
req.notifications = makeNotifications(route.structure_id, req.session.userId);
ws.user = req.currentUser;
ws.notifications = req.notifications;
try { try {
return found.handler(ws, req); return found.handler(ws, req);
@ -450,6 +575,85 @@ app.all("/logout", async (req, res) => {
}); });
}); });
app.get("/_bliss/notifications/key", (req, res) => {
if (!vapidPublicKey) return res.status(503).json({ error: "not configured" });
res.json({ publicKey: vapidPublicKey });
});
app.post("/_bliss/notifications/subscribe", (req, res) => {
const structureId = Number(req.body?.structure_id);
const userId = req.session && req.session.userId;
const subscription = req.body?.subscription;
if (!userId)
return res.status(401).send("log in before enabling notifications");
if (!Number.isInteger(structureId) || !model.getStructure(structureId)) {
return res.status(400).send("unknown structure");
}
if (!model.canAccessStructure(userId, structureId)) {
return res.status(403).send("you cannot access this structure");
}
if (!subscription || !subscription.endpoint) {
return res.status(400).send("missing push subscription");
}
model.upsertNotificationSubscription({
structureId,
userId,
endpoint: subscription.endpoint,
clientId: req.body?.client_id,
subscription,
contentEncoding: req.body?.content_encoding,
});
res.status(201).json({ ok: true });
});
app.get("/_bliss/notification-sw.js", (req, res) => {
res.type("application/javascript").set("Cache-Control", "no-cache").send(`
const openStructures = new Set();
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
self.addEventListener("message", (event) => {
const data = event.data || {};
if (data.type !== "bliss:visibility" || !data.structureId) return;
const id = String(data.structureId);
if (data.state === "open") {
openStructures.add(id);
self.registration.getNotifications({ tag: "bliss:" + id }).then((items) => items.forEach((n) => n.close()));
} else {
openStructures.delete(id);
}
});
self.addEventListener("push", (event) => {
let data = {};
try { data = event.data ? event.data.json() : {}; } catch (_) {}
const structureId = String(data.structureId || "");
if (structureId && openStructures.has(structureId)) return;
event.waitUntil(self.registration.showNotification(data.title || "Bliss", {
body: data.body || "",
tag: structureId ? "bliss:" + structureId : "bliss",
data: { url: data.url || "/" },
}));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const target = event.notification.data && event.notification.data.url || "/";
event.waitUntil(clients.matchAll({ type: "window", includeUncontrolled: true }).then((items) => {
for (const client of items) {
try {
const url = new URL(client.url);
if (url.pathname === target && "focus" in client) return client.focus();
} catch (_) {}
}
if (clients.openWindow) return clients.openWindow(target);
}));
});
`);
});
// _ _ _______ ______ ___ _ _______ __ __ _______ _______ // _ _ _______ ______ ___ _ _______ __ __ _______ _______
// | | _ | || || _ | | | | || || | | || || | // | | _ | || || _ | | | | || || | | || || |
// | || || || _ || | || | |_| || _____|| |_| || _ || _ | // | || || || _ || | || | |_| || _____|| |_| || _ || _ |
@ -467,6 +671,7 @@ function headChrome(headInjection) {
<script src="/js/htmx.js"></script> <script src="/js/htmx.js"></script>
<script src="/js/ace/ace.js"></script> <script src="/js/ace/ace.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script> <script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
<script src="/js/bliss.js"></script>
<script src="/js/bliss_inspector.js"></script> <script src="/js/bliss_inspector.js"></script>
<script> <script>
tailwind.config = { tailwind.config = {
@ -1119,6 +1324,11 @@ app.all("*", async (req, res) => {
// Deliberately NOT the raw session/user object — just {id, username} — // Deliberately NOT the raw session/user object — just {id, username} —
// so structures can greet whoever is logged in without exposing internals. // so structures can greet whoever is logged in without exposing internals.
req.currentUser = currentUserFor(req); req.currentUser = currentUserFor(req);
req.user = req.currentUser;
req.notifications = makeNotifications(
route.structure_id,
req.session.userId,
);
const structure = model.getStructure(route.structure_id); const structure = model.getStructure(route.structure_id);
@ -1128,7 +1338,7 @@ app.all("*", async (req, res) => {
let context = bootstrapContext( let context = bootstrapContext(
route.structure_id, route.structure_id,
route.id, route.id,
{ req, res, eta }, { req, res, eta, notifications: req.notifications },
req.session.userId, req.session.userId,
); );

View file

@ -0,0 +1,20 @@
-- Kernel-owned web-push subscriptions. A subscription is always scoped to the
-- structure that registered it, and send-time fanout re-checks structure
-- visibility before delivery.
CREATE TABLE notification_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
structure_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL,
client_id TEXT,
subscription TEXT NOT NULL,
content_encoding TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(structure_id) REFERENCES structures(id),
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(structure_id, endpoint)
);
CREATE INDEX idx_notification_subscriptions_structure
ON notification_subscriptions (structure_id, user_id);

130
public/js/bliss.js Normal file
View file

@ -0,0 +1,130 @@
(function () {
if (window.Bliss) return;
function pageStructureId() {
return (
document.body?.dataset?.blissStructureId ||
document.querySelector("[data-bliss-structure-id]")?.dataset
?.blissStructureId ||
null
);
}
function clientId() {
const key = "bliss:client_id";
let id = localStorage.getItem(key);
if (!id) {
id =
crypto.randomUUID?.() ||
String(Date.now()) + "-" + Math.random().toString(36).slice(2);
localStorage.setItem(key, id);
}
return id;
}
function b64ToU8(s) {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const raw = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
const a = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) a[i] = raw.charCodeAt(i);
return a;
}
async function registration() {
if (!("serviceWorker" in navigator)) {
throw new Error("Service workers are not supported here.");
}
return navigator.serviceWorker.register("/_bliss/notification-sw.js");
}
async function postVisibility(state) {
if (!("serviceWorker" in navigator)) return;
const reg = await registration().catch(() => null);
reg?.active?.postMessage({
type: "bliss:visibility",
state,
structureId: pageStructureId(),
});
}
document.addEventListener("visibilitychange", () => {
postVisibility(document.visibilityState === "visible" ? "open" : "closed");
});
if (document.readyState === "loading") {
document.addEventListener(
"DOMContentLoaded",
() => postVisibility("open"),
{
once: true,
},
);
} else {
postVisibility("open");
}
window.Bliss = {
structureId: pageStructureId,
clientId,
notifications: {
supported() {
return (
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
},
permission() {
return "Notification" in window ? Notification.permission : "denied";
},
async register(options = {}) {
const structureId = options.structureId || pageStructureId();
if (!structureId) throw new Error("No Bliss structure is active.");
if (!this.supported()) {
throw new Error("Push notifications are not supported here.");
}
const reg = await registration();
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
}
const vapidResponse = await fetch("/_bliss/notifications/key");
if (!vapidResponse.ok) {
throw new Error("Could not load notification key.");
}
const { publicKey } = await vapidResponse.json();
const sub =
(await reg.pushManager.getSubscription()) ||
(await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64ToU8(publicKey),
}));
const ua =
String(navigator.userAgent || "") +
" " +
String(navigator.platform || "");
const isKaiOS = /kaios/i.test(ua) || !!navigator.b2g;
const response = await fetch("/_bliss/notifications/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
structure_id: structureId,
client_id: clientId(),
subscription: sub,
content_encoding: isKaiOS ? "aesgcm" : "aes128gcm",
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail || "Could not register notifications.");
}
await postVisibility("open");
return true;
},
},
};
})();