diff --git a/bliss-cli/bliss b/bliss-cli/bliss
index 2fe9bc5..17c6b18 100755
--- a/bliss-cli/bliss
+++ b/bliss-cli/bliss
@@ -68,6 +68,14 @@ const commands = {
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) ----
async structures() {
out(await c.getJSON("/plumbing/structures"));
diff --git a/bliss-cli/client.js b/bliss-cli/client.js
index a82c14f..c99b47a 100644
--- a/bliss-cli/client.js
+++ b/bliss-cli/client.js
@@ -79,10 +79,21 @@ async function login() {
headers: { "content-type": "application/x-www-form-urlencoded" },
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);
- if (!cookie) {
- // A 200 back from /login means the login form re-rendered => bad creds.
- throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)");
+ const redirected = res.status >= 300 && res.status < 400;
+ if (!redirected || !cookie) {
+ throw new Error(
+ "login failed for user '" +
+ USER +
+ "' — server rejected the credentials (HTTP " +
+ res.status +
+ "). Check BLISS_USER / BLISS_PASS.",
+ );
}
saveCookie(cookie);
return cookie;
@@ -168,6 +179,7 @@ async function getText(urlPath) {
module.exports = {
BASE,
setTarget,
+ login,
request,
getJSON,
getText,
diff --git a/db.js b/db.js
index db795c3..f9ecd64 100644
--- a/db.js
+++ b/db.js
@@ -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) {
path = encodeURI(path);
@@ -890,6 +984,11 @@ module.exports = {
removeMember,
setStructurePrivacy,
setStructurePublic,
+ upsertNotificationSubscription,
+ deleteNotificationSubscription,
+ getNotificationSubscriptions,
+ getSubscribedNotificationUserIds,
+ getStructureMemberUserIds,
createRoute,
getRoutes,
getRoute,
diff --git a/index.js b/index.js
index 3fa42ff..cb8ceea 100644
--- a/index.js
+++ b/index.js
@@ -210,6 +210,7 @@ function makeLibs(structureId, console, memberUserId) {
return dbs[alias];
},
push: push,
+ notifications: makeNotifications(structureId, memberUserId),
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) {
const structure = model.getStructure(structureId);
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 {
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) {
+