@flow-industries/id 0.24.1 → 0.24.4

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.
@@ -347,6 +347,11 @@
347
347
  "type": true,
348
348
  "value": false
349
349
  },
350
+ {
351
+ "name": "MarkNotificationsReadInput",
352
+ "type": true,
353
+ "value": false
354
+ },
350
355
  {
351
356
  "name": "matchScore",
352
357
  "type": false,
@@ -382,6 +387,31 @@
382
387
  "type": false,
383
388
  "value": true
384
389
  },
390
+ {
391
+ "name": "NotificationCounts",
392
+ "type": true,
393
+ "value": false
394
+ },
395
+ {
396
+ "name": "NotificationEntry",
397
+ "type": true,
398
+ "value": false
399
+ },
400
+ {
401
+ "name": "NotificationPage",
402
+ "type": true,
403
+ "value": false
404
+ },
405
+ {
406
+ "name": "NotificationReadResult",
407
+ "type": true,
408
+ "value": false
409
+ },
410
+ {
411
+ "name": "NotificationRefusal",
412
+ "type": true,
413
+ "value": false
414
+ },
385
415
  {
386
416
  "name": "openProfile",
387
417
  "type": false,
@@ -1,6 +1,7 @@
1
1
  export declare function safeReturnTo(value: string, audience: string, sessionPath?: string): string | null;
2
2
  export declare function startBrowserSession(request: Request, audience: string, issuerUrl: string, body: {
3
3
  action: "login" | "account";
4
+ redirect?: boolean;
4
5
  returnTo?: string;
5
6
  signUp?: boolean;
6
7
  accessKeyHash?: string;
@@ -81,7 +81,17 @@ export async function startBrowserSession(request, audience, issuerUrl, body, ap
81
81
  const { handoff } = z
82
82
  .object({ handoff: z.string().regex(opaquePattern) })
83
83
  .parse(await response.json());
84
- return reply({ accountUrl: `${issuerUrl}/account#handoff=${handoff}` });
84
+ const accountUrl = `${issuerUrl}/account#handoff=${handoff}`;
85
+ if (body.redirect)
86
+ return new Response(null, {
87
+ status: 303,
88
+ headers: {
89
+ Location: accountUrl,
90
+ "Cache-Control": "no-store",
91
+ "Referrer-Policy": "no-referrer",
92
+ },
93
+ });
94
+ return reply({ accountUrl });
85
95
  }
86
96
  const returnTo = safeReturnTo(body.returnTo ?? "/", audience, new URL(request.url).pathname);
87
97
  if (!returnTo)
@@ -394,34 +394,12 @@ export function createFlow(options = {}) {
394
394
  });
395
395
  }
396
396
  function openAccount() {
397
- const tab = window.open("about:blank", "_blank");
397
+ const url = new URL(sessionPath, window.location.origin);
398
+ url.searchParams.set("action", "account");
399
+ const tab = window.open(url.href, "_blank");
398
400
  if (!tab)
399
401
  return false;
400
402
  tab.opener = null;
401
- tab.document.title = "Opening Flow ID";
402
- tab.document.body.textContent = "Opening your Flow ID account…";
403
- void fetch(sessionPath, {
404
- method: "POST",
405
- headers: { "Content-Type": "application/json" },
406
- body: JSON.stringify({ action: "account" }),
407
- })
408
- .then(async (response) => {
409
- if (!response.ok)
410
- throw new Error("Account unavailable");
411
- // SAFETY: the app session handler returns the SDK protocol response.
412
- const result = (await response.json());
413
- tab.location.replace(result.accountUrl);
414
- })
415
- .catch(() => {
416
- tab.document.title = "Open Flow ID";
417
- tab.document.body.textContent =
418
- "Your app account could not be opened securely. Return to the app and try again, or open Flow ID and check the signed-in account before making changes. ";
419
- const link = tab.document.createElement("a");
420
- link.href = `${host}/account`;
421
- link.textContent = "Open Flow ID";
422
- link.rel = "noreferrer";
423
- tab.document.body.appendChild(link);
424
- });
425
403
  return true;
426
404
  }
427
405
  async function logout() {
@@ -1,5 +1,5 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
2
+ export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MarkNotificationsReadInput, MethodName, MountFlowWidgetOptions, MountProfileOptions, NotificationCounts, NotificationEntry, NotificationPage, NotificationReadResult, NotificationRefusal, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
3
3
  export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
4
4
  export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
5
5
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
@@ -1,3 +1,3 @@
1
1
  import type { MountProfileOptions, ProfileButtonHandle } from "../types";
2
- /** Mount a native account button that opens Flow ID in a normal browser tab. */
2
+ /** Mount an account button that signs guests in or opens full-account settings. */
3
3
  export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;
@@ -6,7 +6,7 @@ const POSITION_STYLE = {
6
6
  "bottom-right": { bottom: "0", right: "0" },
7
7
  "bottom-left": { bottom: "0", left: "0" },
8
8
  };
9
- /** Mount a native account button that opens Flow ID in a normal browser tab. */
9
+ /** Mount an account button that signs guests in or opens full-account settings. */
10
10
  export function createProfileButton(options) {
11
11
  if (!("document" in globalThis))
12
12
  return { setTheme() { }, destroy() { } };
@@ -81,7 +81,9 @@ export function createProfileButton(options) {
81
81
  function update(refresh = false) {
82
82
  const user = flow.getState().user;
83
83
  name.textContent = user?.username ?? "Flow ID";
84
- button.setAttribute("aria-label", `Open ${user?.username ?? "your"} Flow ID account in a new tab`);
84
+ button.setAttribute("aria-label", !user || user.isGuest
85
+ ? "Sign in to Flow ID"
86
+ : `Open ${user.username} Flow ID account in a new tab`);
85
87
  const identityChanged = user?.id !== lastUser?.id ||
86
88
  user?.username !== lastUser?.username ||
87
89
  user?.isGuest !== lastUser?.isGuest;
@@ -92,8 +94,11 @@ export function createProfileButton(options) {
92
94
  avatar.textContent = user?.username.slice(0, 1).toUpperCase() ?? "F";
93
95
  lastUser = user;
94
96
  }
95
- else if (!refresh || profileRequest)
97
+ else if (!refresh || profileRequest) {
98
+ // Same account: only an explicit refresh refetches, and never on top of
99
+ // a request already in flight.
96
100
  return;
101
+ }
97
102
  if (!user || user.isGuest)
98
103
  return;
99
104
  const controller = new AbortController();
@@ -137,6 +142,19 @@ export function createProfileButton(options) {
137
142
  });
138
143
  }
139
144
  button.addEventListener("click", () => {
145
+ const user = flow.getState().user;
146
+ if (!user || user.isGuest) {
147
+ if (button.disabled)
148
+ return;
149
+ button.disabled = true;
150
+ notice.textContent = "";
151
+ fallback.hidden = true;
152
+ void flow.login().catch(() => {
153
+ button.disabled = false;
154
+ notice.textContent = "Could not start sign-in. Try again.";
155
+ });
156
+ return;
157
+ }
140
158
  const opened = flow.openAccount();
141
159
  notice.textContent = opened
142
160
  ? ""
@@ -4053,6 +4053,166 @@ export declare const securityEvent: import("drizzle-orm/pg-core").PgTableWithCol
4053
4053
  };
4054
4054
  dialect: "pg";
4055
4055
  }>;
4056
+ export declare const notification: import("drizzle-orm/pg-core").PgTableWithColumns<{
4057
+ name: "notification";
4058
+ schema: undefined;
4059
+ columns: {
4060
+ id: import("drizzle-orm/pg-core").PgColumn<{
4061
+ name: "id";
4062
+ tableName: "notification";
4063
+ dataType: "string";
4064
+ columnType: "PgText";
4065
+ data: string;
4066
+ driverParam: string;
4067
+ notNull: true;
4068
+ hasDefault: false;
4069
+ isPrimaryKey: true;
4070
+ isAutoincrement: false;
4071
+ hasRuntimeDefault: false;
4072
+ enumValues: [string, ...string[]];
4073
+ baseColumn: never;
4074
+ identity: undefined;
4075
+ generated: undefined;
4076
+ }, {}, {}>;
4077
+ userId: import("drizzle-orm/pg-core").PgColumn<{
4078
+ name: "user_id";
4079
+ tableName: "notification";
4080
+ dataType: "string";
4081
+ columnType: "PgText";
4082
+ data: string;
4083
+ driverParam: string;
4084
+ notNull: true;
4085
+ hasDefault: false;
4086
+ isPrimaryKey: false;
4087
+ isAutoincrement: false;
4088
+ hasRuntimeDefault: false;
4089
+ enumValues: [string, ...string[]];
4090
+ baseColumn: never;
4091
+ identity: undefined;
4092
+ generated: undefined;
4093
+ }, {}, {}>;
4094
+ type: import("drizzle-orm/pg-core").PgColumn<{
4095
+ name: "type";
4096
+ tableName: "notification";
4097
+ dataType: "string";
4098
+ columnType: "PgText";
4099
+ data: string;
4100
+ driverParam: string;
4101
+ notNull: true;
4102
+ hasDefault: false;
4103
+ isPrimaryKey: false;
4104
+ isAutoincrement: false;
4105
+ hasRuntimeDefault: false;
4106
+ enumValues: [string, ...string[]];
4107
+ baseColumn: never;
4108
+ identity: undefined;
4109
+ generated: undefined;
4110
+ }, {}, {}>;
4111
+ subjectType: import("drizzle-orm/pg-core").PgColumn<{
4112
+ name: "subject_type";
4113
+ tableName: "notification";
4114
+ dataType: "string";
4115
+ columnType: "PgText";
4116
+ data: string;
4117
+ driverParam: string;
4118
+ notNull: true;
4119
+ hasDefault: false;
4120
+ isPrimaryKey: false;
4121
+ isAutoincrement: false;
4122
+ hasRuntimeDefault: false;
4123
+ enumValues: [string, ...string[]];
4124
+ baseColumn: never;
4125
+ identity: undefined;
4126
+ generated: undefined;
4127
+ }, {}, {}>;
4128
+ subjectId: import("drizzle-orm/pg-core").PgColumn<{
4129
+ name: "subject_id";
4130
+ tableName: "notification";
4131
+ dataType: "string";
4132
+ columnType: "PgText";
4133
+ data: string;
4134
+ driverParam: string;
4135
+ notNull: false;
4136
+ hasDefault: false;
4137
+ isPrimaryKey: false;
4138
+ isAutoincrement: false;
4139
+ hasRuntimeDefault: false;
4140
+ enumValues: [string, ...string[]];
4141
+ baseColumn: never;
4142
+ identity: undefined;
4143
+ generated: undefined;
4144
+ }, {}, {}>;
4145
+ occasion: import("drizzle-orm/pg-core").PgColumn<{
4146
+ name: "occasion";
4147
+ tableName: "notification";
4148
+ dataType: "string";
4149
+ columnType: "PgText";
4150
+ data: string;
4151
+ driverParam: string;
4152
+ notNull: true;
4153
+ hasDefault: true;
4154
+ isPrimaryKey: false;
4155
+ isAutoincrement: false;
4156
+ hasRuntimeDefault: false;
4157
+ enumValues: [string, ...string[]];
4158
+ baseColumn: never;
4159
+ identity: undefined;
4160
+ generated: undefined;
4161
+ }, {}, {}>;
4162
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
4163
+ name: "created_at";
4164
+ tableName: "notification";
4165
+ dataType: "date";
4166
+ columnType: "PgTimestamp";
4167
+ data: Date;
4168
+ driverParam: string;
4169
+ notNull: true;
4170
+ hasDefault: true;
4171
+ isPrimaryKey: false;
4172
+ isAutoincrement: false;
4173
+ hasRuntimeDefault: false;
4174
+ enumValues: undefined;
4175
+ baseColumn: never;
4176
+ identity: undefined;
4177
+ generated: undefined;
4178
+ }, {}, {}>;
4179
+ readAt: import("drizzle-orm/pg-core").PgColumn<{
4180
+ name: "read_at";
4181
+ tableName: "notification";
4182
+ dataType: "date";
4183
+ columnType: "PgTimestamp";
4184
+ data: Date;
4185
+ driverParam: string;
4186
+ notNull: false;
4187
+ hasDefault: false;
4188
+ isPrimaryKey: false;
4189
+ isAutoincrement: false;
4190
+ hasRuntimeDefault: false;
4191
+ enumValues: undefined;
4192
+ baseColumn: never;
4193
+ identity: undefined;
4194
+ generated: undefined;
4195
+ }, {}, {}>;
4196
+ seenAt: import("drizzle-orm/pg-core").PgColumn<{
4197
+ name: "seen_at";
4198
+ tableName: "notification";
4199
+ dataType: "date";
4200
+ columnType: "PgTimestamp";
4201
+ data: Date;
4202
+ driverParam: string;
4203
+ notNull: false;
4204
+ hasDefault: false;
4205
+ isPrimaryKey: false;
4206
+ isAutoincrement: false;
4207
+ hasRuntimeDefault: false;
4208
+ enumValues: undefined;
4209
+ baseColumn: never;
4210
+ identity: undefined;
4211
+ generated: undefined;
4212
+ }, {}, {}>;
4213
+ };
4214
+ dialect: "pg";
4215
+ }>;
4056
4216
  export declare const accountErasure: import("drizzle-orm/pg-core").PgTableWithColumns<{
4057
4217
  name: "account_erasure";
4058
4218
  schema: undefined;
@@ -692,6 +692,83 @@ export const securityEvent = pgTable("security_event", {
692
692
  }, (table) => [
693
693
  index("security_event_user_created_at_idx").on(table.userId, table.createdAt),
694
694
  ]);
695
+ // The per-recipient notification index (AUTH-351): one address row per user per
696
+ // thing they were told about, recording WHO was addressed, of WHAT type, about
697
+ // WHICH referent and WHEN — and nothing a viewer reads. There is deliberately
698
+ // no title, body, href or payload column: every rendered field is produced by
699
+ // that type's resolver from the live source at read time, so a referent that
700
+ // was deleted or cancelled, or that this viewer may no longer see, suppresses
701
+ // the entry instead of rendering it. A row carrying a copy of the text would
702
+ // outlive its source and keep showing what nobody may see any more, which is
703
+ // the guarantee talk/server/inbox.ts gets for free from indexing on the
704
+ // source's own table and auth's heterogeneous sources have to keep by
705
+ // construction.
706
+ //
707
+ // Keyed on user.id (the JWT sub) so rows survive the in-place guest->full
708
+ // upgrade, and cascade on user delete. Guests hold notifications like anyone
709
+ // else — with no email and no linked Discord, in-app is the only channel that
710
+ // can ever reach them.
711
+ //
712
+ // `subject_id` is null for a type addressed at the account rather than at a
713
+ // row. `occasion` is the second address axis inside one subject: two notices
714
+ // about the same referent that are genuinely different notices rather than a
715
+ // retry of one (a room event's T-60, T-10 and start reminders all address the
716
+ // same occurrence). It is an opaque address token, never content -- a resolver
717
+ // still renders every field from the live source, and an empty string is the
718
+ // default for a type that addresses a subject only once.
719
+ //
720
+ // `read_at` is set only by the mark-read route, `seen_at` by the panel-open
721
+ // route, and a read row is always also seen, which is what lets one partial
722
+ // index answer both counts.
723
+ //
724
+ // The three instants are `timestamp(3)`: a JS Date is millisecond-resolution,
725
+ // so a column at Postgres's default microsecond resolution cannot be addressed
726
+ // exactly by the keyset cursor or by a `before` echoed back from a response.
727
+ // The default is `now() at time zone 'utc'` rather than plain `now()`, which a
728
+ // naive column resolves through the session TimeZone -- drizzle reads a naive
729
+ // column back as UTC in every case, so a non-UTC session would silently shift
730
+ // every row against the app clock that every predicate here uses.
731
+ export const notification = pgTable("notification", {
732
+ id: text("id").primaryKey(),
733
+ userId: text("user_id")
734
+ .notNull()
735
+ .references(() => user.id, { onDelete: "cascade" }),
736
+ type: text("type").notNull(),
737
+ subjectType: text("subject_type").notNull(),
738
+ subjectId: text("subject_id"),
739
+ occasion: text("occasion").notNull().default(""),
740
+ createdAt: timestamp("created_at", { precision: 3 })
741
+ .notNull()
742
+ .default(sql `(now() at time zone 'utc')`),
743
+ readAt: timestamp("read_at", { precision: 3 }),
744
+ seenAt: timestamp("seen_at", { precision: 3 }),
745
+ }, (table) => [
746
+ // The keyset page: WHERE user_id = ? ORDER BY created_at DESC, id DESC,
747
+ // which is also the order the (created_at, id) cursor resumes from.
748
+ // Deliberately ASC, like security_event_user_created_at_idx: a DESC index
749
+ // is built NULLS LAST while a bare `ORDER BY ... DESC` asks for NULLS
750
+ // FIRST, so the pathkeys never match and the planner sorts instead of
751
+ // walking the index. Ascending, it serves the page as a backward scan.
752
+ index("notification_user_created_at_idx").on(table.userId, table.createdAt, table.id),
753
+ // The badge count — kept partial so it stays small no matter how much
754
+ // history a long-lived account accumulates. Unseen rows are a subset of
755
+ // unread ones (marking read also stamps seen), so this one index serves
756
+ // both numbers, and the panel-open stamp carries a read_at term for the
757
+ // same reason: `seen_at is null` on its own is not a predicate anything
758
+ // here can serve.
759
+ index("notification_user_unread_idx")
760
+ .on(table.userId, table.createdAt)
761
+ .where(sql `${table.readAt} is null`),
762
+ // Idempotency: a producer that retries a fan-out writes the same address
763
+ // twice and the second insert must be a no-op, never a duplicate entry.
764
+ // Indexed on coalesce() rather than on the column because Postgres treats
765
+ // NULLs as distinct, which would let an account-scoped type (null subject)
766
+ // duplicate freely; an empty string is not a valid id, so it can never
767
+ // collide with a real one. `occasion` is part of the key, so the three
768
+ // reminders one occurrence sends are three addresses while a retry of any
769
+ // one of them still collapses into the row it already wrote.
770
+ uniqueIndex("notification_user_type_subject_idx").on(table.userId, table.type, table.subjectType, sql `coalesce(${table.subjectId}, '')`, table.occasion),
771
+ ]);
695
772
  // The erasure ledger: one row per deleted account, appended in the same
696
773
  // transaction that deletes the user row (src/account-erasure.ts). `user_id` is
697
774
  // the sub every downstream service keyed its data on and is deliberately NOT a
@@ -93,6 +93,14 @@ export async function handleSessionRequest(request, opts = {}) {
93
93
  }
94
94
  switch (request.method) {
95
95
  case "GET": {
96
+ if (callback.searchParams.get("action") === "account") {
97
+ try {
98
+ return await startBrowserSession(request, audience, issuerUrl, { action: "account", redirect: true }, apiUrl);
99
+ }
100
+ catch {
101
+ return json({ state: null }, 502);
102
+ }
103
+ }
96
104
  if (new URL(request.url).searchParams.get("refresh") === "1") {
97
105
  const token = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
98
106
  if (!token)
@@ -67,6 +67,7 @@ export declare const CATALOG: Catalog;
67
67
  */
68
68
  export declare const STARTING_OUTFIT: {
69
69
  hair: string;
70
+ ears: string;
70
71
  hat: string;
71
72
  face: string;
72
73
  top: string;
@@ -16,6 +16,7 @@
16
16
  * promotes to a `cosmetic` table when non-engineers must edit it live.
17
17
  */
18
18
  import { isString } from "../json";
19
+ import earItems from "./ear-items.json";
19
20
  import hairItems from "./hair-items.json";
20
21
  /**
21
22
  * Region pairs that physically overlap, declared once in ONE direction and
@@ -60,6 +61,7 @@ const OVERLAPS = (() => {
60
61
  */
61
62
  const SLOTS = {
62
63
  hair: { label: "Hair", required: true },
64
+ ears: { label: "Ears", required: true },
63
65
  hat: { label: "Headwear", required: false },
64
66
  face: { label: "Face", required: false },
65
67
  top: { label: "Top", required: true },
@@ -102,6 +104,13 @@ export function catalogOf(items) {
102
104
  * there names nothing the renderer can draw.
103
105
  */
104
106
  export const COSMETICS = [
107
+ ...earItems.map((item) => ({
108
+ ...item,
109
+ slot: "ears",
110
+ regions: item.id === "ears_original" ? [] : ["ears"],
111
+ hides: item.id === "ears_original" ? [] : ["ears"],
112
+ paintable: [],
113
+ })),
105
114
  ...hairItems.map((item) => ({
106
115
  ...item,
107
116
  slot: "hair",
@@ -193,6 +202,7 @@ export const CATALOG = catalogOf(COSMETICS);
193
202
  */
194
203
  export const STARTING_OUTFIT = {
195
204
  hair: "hair_original",
205
+ ears: "ears_original",
196
206
  hat: "",
197
207
  face: "",
198
208
  top: "hoodie",
@@ -0,0 +1,37 @@
1
+ [
2
+ {
3
+ "id": "ears_original",
4
+ "name": "Soft point",
5
+ "description": "Compact ears with gently tapered tips and a soft, rounded shape."
6
+ },
7
+ {
8
+ "id": "ears_small_round",
9
+ "name": "Small rounded",
10
+ "description": "Small rounded ears tucked close to the sides of the head."
11
+ },
12
+ {
13
+ "id": "ears_short_point",
14
+ "name": "Short pointed",
15
+ "description": "Compact pointed ears with a soft rim and a slight upward tilt."
16
+ },
17
+ {
18
+ "id": "ears_long_swept",
19
+ "name": "Elf ears",
20
+ "description": "Long pointed elf ears sweep outward and upward from the sides of the head."
21
+ },
22
+ {
23
+ "id": "ears_cat_upright",
24
+ "name": "Upright cat",
25
+ "description": "Two upright triangular cat ears with broad bases and inset centers."
26
+ },
27
+ {
28
+ "id": "ears_bear_round",
29
+ "name": "Rounded bear",
30
+ "description": "A pair of soft round bear ears perched on the top of the head."
31
+ },
32
+ {
33
+ "id": "ears_rabbit_upright",
34
+ "name": "Upright rabbit",
35
+ "description": "Tall upright rabbit ears with rounded tips and long inset centers."
36
+ }
37
+ ]
@@ -15,13 +15,24 @@ export const NOTIFICATION_SETTINGS = [
15
15
  key: "room_events",
16
16
  group: "events",
17
17
  label: "Event reminders",
18
- description: "How to be reminded about events you are interested in",
18
+ // Two honesty problems, both fixed in the wording rather than by widening
19
+ // the enum (offering email is AUTH-177). It used to promise "In the menu",
20
+ // a surface that did not exist and a delivery that never happened; and it
21
+ // read as per-person routing, which the Discord half is not — a room wired
22
+ // to Discord posts one announcement to its channel for everyone in it, and
23
+ // no per-user preference can suppress somebody else's room's post.
24
+ description: "How you hear about an event you marked Interested in. A room wired to Discord announces every event in its channel for everyone, whichever of these you pick.",
19
25
  type: "enum",
20
26
  default: "in_app",
27
+ // The stored values are load-bearing: they are in every user_settings blob
28
+ // and `in_app` is the channel name on the dispatch ledger.
21
29
  options: [
22
- { value: "in_app", label: "In the menu" },
23
- { value: "discord", label: "Discord" },
24
- { value: "off", label: "Off" },
30
+ { value: "in_app", label: "In your Flow notifications" },
31
+ {
32
+ value: "discord",
33
+ label: "In the room's Discord — or here, if the room has none",
34
+ },
35
+ { value: "off", label: "Don't remind me" },
25
36
  ],
26
37
  },
27
38
  ];
@@ -231,6 +231,22 @@ export type AccountExportGameEvent = {
231
231
  reason: string | null;
232
232
  detail: RawRecord | null;
233
233
  };
234
+ /**
235
+ * One in-app notification the account was addressed with. Deliberately the
236
+ * stored row and nothing more: the table holds no title, body or href, so an
237
+ * export cannot carry content the live source may since have withdrawn.
238
+ */
239
+ export type AccountExportNotification = {
240
+ id: string;
241
+ type: string;
242
+ subjectType: string;
243
+ subjectId: string | null;
244
+ /** Which notice about that subject it was; empty for a once-only type. */
245
+ occasion: string;
246
+ createdAt: string;
247
+ readAt: string | null;
248
+ seenAt: string | null;
249
+ };
234
250
  /** Where the rest of a person's Flow data lives, so one export names every
235
251
  * service that has to be asked. */
236
252
  export type AccountExportOtherService = {
@@ -273,6 +289,7 @@ export type AccountExport = {
273
289
  rooms: AccountExportRooms;
274
290
  roomEvents: AccountExportRoomEvents;
275
291
  gameEvents: AccountExportGameEvent[];
292
+ notifications: AccountExportNotification[];
276
293
  securityActivity: SecurityActivityEntry[];
277
294
  otherServices: AccountExportOtherService[];
278
295
  /** Dotted names of every collection that hit the row cap (newest rows kept). */
@@ -175,3 +175,21 @@ export type AccountRegistration = {
175
175
  walletAddress: string | null;
176
176
  credential: FlowCredential | null;
177
177
  };
178
+ export type AuthorizationTransaction = {
179
+ expiresAt: number;
180
+ accessKeyHash?: string;
181
+ credentialId?: string;
182
+ audience: string;
183
+ signUp: boolean;
184
+ guestToken?: string;
185
+ currentUser: {
186
+ id: string;
187
+ username: string;
188
+ } | null;
189
+ };
190
+ export type AuthPageBootstrap = {
191
+ config: AuthConfig;
192
+ transaction?: string;
193
+ context?: AuthorizationTransaction;
194
+ error?: string;
195
+ };
@@ -81,7 +81,7 @@ export type CosmeticMaterial = (typeof COSMETIC_MATERIALS)[number];
81
81
  * that shrank whenever a slot emptied would churn the contract every time
82
82
  * content lands.
83
83
  */
84
- export type CosmeticSlot = "hat" | "hair" | "face" | "top" | "bottom" | "feet";
84
+ export type CosmeticSlot = "hat" | "hair" | "ears" | "face" | "top" | "bottom" | "feet";
85
85
  /** One item in the catalog. */
86
86
  export interface CosmeticItem {
87
87
  id: string;
@@ -41,11 +41,14 @@ export interface AuthEventRecord {
41
41
  durationMs: number | null;
42
42
  traceId: string | null;
43
43
  /** Room-event scheduler fields: which series and dated instance a transition
44
- * or dispatch was about, and for a dispatch, which reminder and how many
45
- * subscribers it reached. */
44
+ * or dispatch was about, and for a dispatch, which reminder went out on
45
+ * which channel and how many rows it wrote. `channel` is explicit because
46
+ * the alternative was reading a `:discord` suffix off `dispatchKind`, which
47
+ * every consumer then has to parse to tell two channels apart. */
46
48
  roomEventId: string | null;
47
49
  occurrenceId: string | null;
48
50
  dispatchKind: string | null;
51
+ channel: string | null;
49
52
  recipients: number | null;
50
53
  /** Account-lifecycle fields: whether the account was a guest, and what an
51
54
  * erasure or export touched, as per-section row counts. */
@@ -1,7 +1,7 @@
1
- export type { AccountDeleteRequest, AccountDeleteResponse, AccountErasureCounts, AccountErasureEntry, AccountErasureFeed, AccountErasureResult, AccountExport, AccountExportAccount, AccountExportActionEvent, AccountExportActionSession, AccountExportAttendance, AccountExportAuthoredEvent, AccountExportCharacterStateTime, AccountExportFollow, AccountExportGameEvent, AccountExportOtherService, AccountExportOwnedRoom, AccountExportPasskey, AccountExportRoomEvents, AccountExportRoomMembership, AccountExportRoomPlayerState, AccountExportRoomPresence, AccountExportRoomRestriction, AccountExportRoomSettings, AccountExportRooms, AccountExportRsvp, AccountExportSettingsSurface, AccountExportStudySubject, AccountExportXpGrant, AccountExportXpSummary, } from "./account-data";
1
+ export type { AccountDeleteRequest, AccountDeleteResponse, AccountErasureCounts, AccountErasureEntry, AccountErasureFeed, AccountErasureResult, AccountExport, AccountExportAccount, AccountExportActionEvent, AccountExportActionSession, AccountExportAttendance, AccountExportAuthoredEvent, AccountExportCharacterStateTime, AccountExportFollow, AccountExportGameEvent, AccountExportNotification, AccountExportOtherService, AccountExportOwnedRoom, AccountExportPasskey, AccountExportRoomEvents, AccountExportRoomMembership, AccountExportRoomPlayerState, AccountExportRoomPresence, AccountExportRoomRestriction, AccountExportRoomSettings, AccountExportRooms, AccountExportRsvp, AccountExportSettingsSurface, AccountExportStudySubject, AccountExportXpGrant, AccountExportXpSummary, } from "./account-data";
2
2
  export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
3
3
  export type { AtprotoCompletion, AtprotoStartRequest, AtprotoStatus, } from "./atproto";
4
- export type { AccountRegistration, AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyEnrollment, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
4
+ export type { AccountRegistration, AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthorizationTransaction, AuthPageBootstrap, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyEnrollment, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
5
5
  export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
6
6
  export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
7
7
  export type { AccountDataDownload, AccountDataIdentity, BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, Loadable, ProfileData, ProfileIdentity, SignInWithEmailProps, SignUpProps, } from "./dialog";
@@ -11,6 +11,7 @@ export type { GameEventReport, GameLifecycleEventName, GameLifecycleFeedEvent, G
11
11
  export { GAME_LIFECYCLE_EVENT_NAMES } from "./game";
12
12
  export type { AccountProfileData } from "./landing";
13
13
  export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
14
+ export type { MarkNotificationsReadInput, NotificationCounts, NotificationEntry, NotificationPage, NotificationReadResult, NotificationRefusal, } from "./notifications";
14
15
  export type { OtpVerificationFailure, OtpVerificationResult, VerificationAdapter, } from "./otp";
15
16
  export type { Call, ConnectCapabilities, ConnectResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
16
17
  export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * In-app notifications — the shapes `/api/notifications` speaks (AUTH-351).
3
+ *
4
+ * A stored notification is an address row carrying no content, so every field
5
+ * of a {@link NotificationEntry} below is rendered at read time from the live
6
+ * source by that type's resolver. An entry therefore only exists while its
7
+ * referent does and while this viewer may still see it; nothing here is a copy
8
+ * of anything, and a client must not cache an entry as a durable record.
9
+ */
10
+ /**
11
+ * One resolved notification, ready to render.
12
+ *
13
+ * `occurredAt` is the *source's* own instant (a reminder's occurrence start),
14
+ * which is what a panel formats relative time from — the server never bakes
15
+ * "in 10 minutes" into a polled response. `createdAt` is when the user was
16
+ * addressed, and it is what the page is ordered and paged on. `href` is a path
17
+ * inside the consuming app (`/events/<id>`), never an absolute URL: auth does
18
+ * not know which surface is rendering it.
19
+ */
20
+ export interface NotificationEntry {
21
+ id: string;
22
+ type: string;
23
+ title: string;
24
+ body: string | null;
25
+ href: string | null;
26
+ /** A coarse family name the surface maps to its own icon set. */
27
+ icon: string;
28
+ occurredAt: string;
29
+ createdAt: string;
30
+ readAt: string | null;
31
+ seenAt: string | null;
32
+ }
33
+ /**
34
+ * Response of `GET /api/notifications`. A full page is returned whenever that
35
+ * many visible entries exist, so a suppressed row never eats a slot; feed
36
+ * `nextCursor` back as `?cursor=` until it is null.
37
+ */
38
+ export interface NotificationPage {
39
+ notifications: NotificationEntry[];
40
+ nextCursor: string | null;
41
+ }
42
+ /**
43
+ * Response of `GET /api/notifications/unread-count`. `unseen` is the bell's
44
+ * badge, cleared by `POST /api/notifications/seen` when the panel opens; a
45
+ * list read writes nothing. `unread` is the per-entry styling count and is
46
+ * only cleared by `POST /api/notifications/read`. Both are counted over stored
47
+ * rows without resolving them, so either may count an entry that a list read
48
+ * would suppress.
49
+ */
50
+ export interface NotificationCounts {
51
+ unread: number;
52
+ unseen: number;
53
+ }
54
+ /** Response of `POST /api/notifications/read`: rows this call changed, plus the fresh counts. */
55
+ export interface NotificationReadResult extends NotificationCounts {
56
+ updated: number;
57
+ }
58
+ /** Body of `POST /api/notifications/read` — exactly one of the two. */
59
+ export interface MarkNotificationsReadInput {
60
+ ids?: string[];
61
+ /** ISO instant; marks every notification addressed at or before it. */
62
+ before?: string;
63
+ }
64
+ /** Machine-readable refusal codes the notification routes answer with. */
65
+ export type NotificationRefusal = "invalid_cursor";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * In-app notifications — the shapes `/api/notifications` speaks (AUTH-351).
3
+ *
4
+ * A stored notification is an address row carrying no content, so every field
5
+ * of a {@link NotificationEntry} below is rendered at read time from the live
6
+ * source by that type's resolver. An entry therefore only exists while its
7
+ * referent does and while this viewer may still see it; nothing here is a copy
8
+ * of anything, and a client must not cache an entry as a durable record.
9
+ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.24.1",
3
+ "version": "0.24.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",