@t3b/hub-guard 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -182,16 +182,58 @@ if (!guard.allows(r.session, "deliveries", "write")) return guard.refusePage(req
182
182
  3. **Show only what may be used** — build the menu from `pageAccess(session, key)` and switch off buttons on a
183
183
  Read-only page.
184
184
 
185
+ ## Activity: your change log on the Hub's page (0.6.0, WORKFLOWS §16)
186
+
187
+ The Hub's Settings → Activity lists every change made in the Hub and in every app. Nothing is copied to the Hub: when
188
+ the page opens, the Hub asks your app for its lines with its own pass, and your app answers from its own log.
189
+
190
+ 1. **Name the door** in the manifest and in the health answer (the Hub reads the manifest again by itself when the
191
+ health answer names a door it does not hold):
192
+
193
+ ```ts
194
+ // manifest: { ..., activity: "/api/hub/activity" } health: healthResponse(req, check, { ...cfg, activity: "/api/hub/activity" })
195
+ ```
196
+
197
+ 2. **Answer it** from your own log - only the lines that are someone changing something (settings, people and
198
+ access, money, writes to Shopify or a carrier), worded in English like the Hub:
199
+
200
+ ```ts
201
+ // src/app/api/hub/activity/route.ts
202
+ import { activityResponse } from "@t3b/hub-guard";
203
+ export async function GET(req: Request) {
204
+ return activityResponse(req, async (q) => {
205
+ // q: { from, to, limit, before: { at, id } | null, person (Hub id) | null, store (Hub key) | null }
206
+ // newest first by `at`, then your id highest first; only lines after `before` in that order; at most q.limit
207
+ const rows = await readMyLog(q);
208
+ return {
209
+ lines: rows.map((r) => ({ id: String(r.id), at: r.at, person: r.hubPersonId, what: r.what, detail: r.detail, store: r.store, place: r.branch })),
210
+ count: await countMyLog(q), // the lines in these dates, person and store, all pages together
211
+ };
212
+ }, cfg);
213
+ }
214
+ ```
215
+
216
+ - `at`: ISO 8601 in UTC at your database's full precision (Postgres: `to_char(at at time zone 'UTC',
217
+ 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`), and compare `before.at` at that same precision - a coarser time skips lines.
218
+ - `person`: the Hub person's id (the pass gave it to you); `actor` instead, for a line no person made
219
+ ("International Orders, on its own").
220
+ - `store`: the Hub's store key; a module serving one store answers nothing when asked for another.
221
+ - The Hub drops a line out of shape (`cleanActivityLine`) and cuts long words: `what` 200, `detail` 400,
222
+ `actor`/`place` 60 characters. It waits 5 seconds; an app that does not answer is named on the page.
223
+
185
224
  ## What it enforces
186
225
 
187
226
  - `/hub/enter?ticket=…`: an EdDSA ticket signed by the Hub's published key, for this module, from this environment's Hub, 60 seconds, single use → sets the module session (HttpOnly, Secure, SameSite=Strict) and answers a short "Opening …" page that moves on to the page (0.4.0: a SameSite=Strict cookie set on a redirect ending a navigation from another site is not sent on it). `appName` names the app on that page; `landing(path)` maps the pass's page (e.g. `"/"` → `"/app"`), never off the module's own address.
188
227
  - Every other request: no session → 403 (HTML for browsers, JSON for APIs), on **every host** the module answers on. Idle 180 minutes (0.5.0), absolute 8 hours. Every 15 minutes the Hub is asked (with the app's Vercel ID) whether the person's session still stands. A Hub blip keeps the session until the next check.
189
- - Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health` and what you add) never need a session — they carry their own locks (the Hub's pass, a cron secret).
228
+ - Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health`, `/api/hub/activity` and what you add) never need a session — they carry their own locks (the Hub's pass, a cron secret).
190
229
  - Cloudflare Access proof checked first when the team and audience are set.
191
- - `verifyRelay`, `manifestResponse`, `healthResponse`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay also for these exact bytes and at most 5 minutes old; a call at most 60 seconds old.
230
+ - `verifyRelay`, `manifestResponse`, `healthResponse`, `activityResponse`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay also for these exact bytes and at most 5 minutes old; a call at most 60 seconds old.
192
231
 
193
232
  ## Changes
194
233
 
234
+ - **0.6.0** (WORKFLOWS §16): Activity - `activityResponse` answers the module's own change log to the Hub's pass for
235
+ `activity` (`readActivityQuery`, `cleanActivityLine`, `ActivityLine`, `ActivityQuery`); `healthResponse(..., { activity })`
236
+ names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.5.0 keeps working.
195
237
  - **0.5.0** (D-062, A-509): the session's idle limit is 180 minutes (was 15); the Hub counts the 15-minute check as activity, so the person's Hub session stays alive while they work in the module.
196
238
  - **0.4.0** (WORKFLOWS §14): page access — the pass's `pages` in the session, refreshed at every 15-minute check (`sessionCheckPages`); `guard.allows()`, `guard.refusePage()`, `pageAllowed`, `pageAccess`, `readPages`; `healthResponse(..., { pages })` sends the page list's fingerprint (`pagesFingerprint`). `enter()` answers an "Opening …" page instead of a 303 (`appName`, `landing` options; `openingPage` exported). A module on 0.3.x keeps working, but the Hub opens it for the owner only until it lists its pages.
197
239
  - **0.3.0** (WORKFLOWS 9.3): `alert()` — report a problem to the Hub, or say it is over.
@@ -69,7 +69,7 @@ export type HubCallCheck = {
69
69
  export declare function verifyHubCall(req: Request, opts: {
70
70
  moduleId: string;
71
71
  keys: JWTVerifyGetKey;
72
- use: "manifest" | "health";
72
+ use: "manifest" | "health" | "activity";
73
73
  now?: Date;
74
74
  environment?: string;
75
75
  }): Promise<HubCallCheck>;
@@ -92,7 +92,7 @@ export type Connection = {
92
92
  store?: string | null;
93
93
  };
94
94
  /** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
95
- export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[]): Promise<{
95
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string): Promise<{
96
96
  status: number;
97
97
  body: {
98
98
  ok: boolean;
@@ -100,9 +100,60 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
100
100
  connections: Connection[];
101
101
  checked_at: string;
102
102
  pages?: string;
103
+ activity?: string;
103
104
  };
104
105
  }>;
105
106
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
106
107
  export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
107
108
  pages?: PageDef[];
109
+ activity?: string;
108
110
  }): Promise<Response>;
111
+ /** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
112
+ export type ActivityLine = {
113
+ /** the app's own row id; with `at` it fixes the order and the page boundary */
114
+ id: string;
115
+ /** when, ISO 8601 in UTC at the app's full precision (up to microseconds), e.g. "2026-10-03T15:49:49.600123Z" */
116
+ at: string;
117
+ /** the Hub person who made the change (their Hub id), or null */
118
+ person: string | null;
119
+ /** who, when no Hub person did it - "International Orders, on its own" */
120
+ actor?: string | null;
121
+ what: string;
122
+ detail?: string | null;
123
+ /** the Hub's store key the change belongs to, if one ("kw") */
124
+ store?: string | null;
125
+ /** a finer place inside the store, if one (a branch: "Mall 360") */
126
+ place?: string | null;
127
+ };
128
+ /**
129
+ * What the Hub asks for. Answer the lines newest first - by `at`, then by your own id, highest first - and only
130
+ * those after `before` in that order (compare `at` at the precision you answered it with). At most `limit`.
131
+ */
132
+ export type ActivityQuery = {
133
+ from: Date;
134
+ to: Date;
135
+ limit: number;
136
+ before: {
137
+ at: string;
138
+ id: string;
139
+ } | null;
140
+ /** only changes made by this Hub person */
141
+ person: string | null;
142
+ /** only changes for this Hub store key; a module serving one store answers nothing for another */
143
+ store: string | null;
144
+ };
145
+ export declare const ACTIVITY_AT_RE: RegExp;
146
+ export declare const ACTIVITY_ID_RE: RegExp;
147
+ export declare const ACTIVITY_MAX_LIMIT = 201;
148
+ /** The Hub's question, from the request's address; null when anything is out of shape (never a wider read). */
149
+ export declare function readActivityQuery(url: URL): ActivityQuery | null;
150
+ /** A line as the Hub accepts it: anything out of shape is dropped, long words are cut. */
151
+ export declare function cleanActivityLine(raw: unknown): ActivityLine | null;
152
+ /**
153
+ * §16: the module's change log, only to the Hub's pass for "activity"; anyone else gets 401 and nothing. `read`
154
+ * answers the lines for the question (see ActivityQuery) and how many lines the dates, person and store hold in all.
155
+ */
156
+ export declare function activityResponse(req: Request, read: (q: ActivityQuery) => Promise<{
157
+ lines: ActivityLine[];
158
+ count: number;
159
+ }>, cfg: Omit<AnswerConfig, "token">): Promise<Response>;
@@ -124,7 +124,7 @@ export async function manifestResponse(req, manifest, cfg) {
124
124
  return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
125
125
  }
126
126
  /** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
127
- export async function healthAnswer(check, pages) {
127
+ export async function healthAnswer(check, pages, activity) {
128
128
  let connections;
129
129
  try {
130
130
  connections = await check();
@@ -142,6 +142,8 @@ export async function healthAnswer(check, pages) {
142
142
  checked_at: new Date().toISOString(),
143
143
  // §14.5: the page list's fingerprint - when it changes, the Hub reads the manifest again by itself
144
144
  ...(pages ? { pages: await pagesFingerprint(pages) } : {}),
145
+ // §16: the app's Activity door; when it differs from the one the Hub holds, the Hub reads the manifest again
146
+ ...(activity ? { activity } : {}),
145
147
  },
146
148
  };
147
149
  }
@@ -150,6 +152,84 @@ export async function healthResponse(req, check, cfg) {
150
152
  const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
151
153
  if (!v.ok)
152
154
  return jsonResponse(401, { error: "hub_only", reason: v.reason });
153
- const a = await healthAnswer(check, cfg.pages);
155
+ const a = await healthAnswer(check, cfg.pages, cfg.activity);
154
156
  return jsonResponse(a.status, a.body);
155
157
  }
158
+ export const ACTIVITY_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
159
+ export const ACTIVITY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
160
+ export const ACTIVITY_MAX_LIMIT = 201;
161
+ const ACTIVITY_MAX_DAYS = 400;
162
+ const HUB_PERSON_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
163
+ const clip = (v, n) => (typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null);
164
+ /** The Hub's question, from the request's address; null when anything is out of shape (never a wider read). */
165
+ export function readActivityQuery(url) {
166
+ const q = url.searchParams;
167
+ const from = Date.parse(q.get("from") ?? "");
168
+ const to = Date.parse(q.get("to") ?? "");
169
+ const limit = Number(q.get("limit") ?? "");
170
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from || to - from > ACTIVITY_MAX_DAYS * 86_400_000)
171
+ return null;
172
+ if (!Number.isInteger(limit) || limit < 1 || limit > ACTIVITY_MAX_LIMIT)
173
+ return null;
174
+ const bAt = q.get("before_at");
175
+ const bId = q.get("before_id");
176
+ if ((bAt === null) !== (bId === null))
177
+ return null;
178
+ if (bAt !== null && (!ACTIVITY_AT_RE.test(bAt) || !ACTIVITY_ID_RE.test(bId ?? "")))
179
+ return null;
180
+ const person = q.get("person");
181
+ if (person !== null && !HUB_PERSON_RE.test(person))
182
+ return null;
183
+ const store = q.get("store");
184
+ if (store !== null && !/^[a-z0-9-]{1,32}$/.test(store))
185
+ return null;
186
+ return { from: new Date(from), to: new Date(to), limit, before: bAt !== null ? { at: bAt, id: bId } : null, person, store };
187
+ }
188
+ /** A line as the Hub accepts it: anything out of shape is dropped, long words are cut. */
189
+ export function cleanActivityLine(raw) {
190
+ const o = (raw ?? {});
191
+ const id = typeof o.id === "number" && Number.isSafeInteger(o.id) ? String(o.id) : o.id;
192
+ if (typeof id !== "string" || !ACTIVITY_ID_RE.test(id))
193
+ return null;
194
+ if (typeof o.at !== "string" || !ACTIVITY_AT_RE.test(o.at) || !Number.isFinite(Date.parse(o.at)))
195
+ return null;
196
+ const what = clip(o.what, 200);
197
+ if (!what)
198
+ return null;
199
+ return {
200
+ id,
201
+ at: o.at,
202
+ person: typeof o.person === "string" && HUB_PERSON_RE.test(o.person) ? o.person : null,
203
+ actor: clip(o.actor, 60),
204
+ what,
205
+ detail: clip(o.detail, 400),
206
+ store: typeof o.store === "string" && /^[a-z0-9-]{1,32}$/.test(o.store) ? o.store : null,
207
+ place: clip(o.place, 60),
208
+ };
209
+ }
210
+ /**
211
+ * §16: the module's change log, only to the Hub's pass for "activity"; anyone else gets 401 and nothing. `read`
212
+ * answers the lines for the question (see ActivityQuery) and how many lines the dates, person and store hold in all.
213
+ */
214
+ export async function activityResponse(req, read, cfg) {
215
+ const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "activity", now: cfg.now, environment: cfg.environment });
216
+ if (!v.ok)
217
+ return jsonResponse(401, { error: "hub_only", reason: v.reason });
218
+ const q = readActivityQuery(new URL(req.url));
219
+ if (!q)
220
+ return jsonResponse(400, { error: "bad_query" });
221
+ let got;
222
+ try {
223
+ got = await read(q);
224
+ }
225
+ catch (e) {
226
+ console.error("[hub-guard] activity read failed:", e instanceof Error ? e.message : String(e));
227
+ return jsonResponse(500, { error: "read_failed" });
228
+ }
229
+ const lines = (Array.isArray(got?.lines) ? got.lines : [])
230
+ .map(cleanActivityLine)
231
+ .filter((l) => l !== null)
232
+ .slice(0, q.limit);
233
+ const count = Number.isSafeInteger(got?.count) && got.count >= 0 ? got.count : lines.length;
234
+ return jsonResponse(200, { lines, count });
235
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.
2
2
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
3
3
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
4
4
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
5
- export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
5
+ export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, type ActivityLine, type ActivityQuery, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
6
6
  export { checkCloudflareAccess } from "./cf-access.js";
7
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
8
8
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { Guard, openingPage } from "./guard.js";
2
2
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
3
3
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
4
4
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
5
- export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
5
+ export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.js";
6
6
  export { checkCloudflareAccess } from "./cf-access.js";
7
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
8
8
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The3Beez Hub guard: a module opens only by a Hub-signed ticket and refuses everything else. No key from the Hub: towards the Hub the app shows its own Vercel ID; from the Hub it accepts only what is signed by the key the Hub publishes. Entry route, refusal page, module session, idle rule, 15-minute re-check, Cloudflare Access proof, broker and audit clients, manifest and health answers for the Hub only. Web Crypto only: runs in Node 20+ and in edge runtimes.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",