@trylight/app 0.1.9 → 0.2.1

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.
Files changed (69) hide show
  1. package/README.md +3 -18
  2. package/dist/auth/config.d.ts +5 -0
  3. package/dist/auth/config.js +16 -0
  4. package/dist/auth/context.d.ts +11 -0
  5. package/dist/auth/context.js +1 -0
  6. package/dist/auth/core.d.ts +64 -0
  7. package/dist/auth/core.js +50 -0
  8. package/dist/auth/db.d.ts +7 -0
  9. package/dist/auth/db.js +57 -0
  10. package/dist/auth/lookup.d.ts +47 -0
  11. package/dist/auth/lookup.js +120 -0
  12. package/dist/auth/mode.d.ts +8 -0
  13. package/dist/auth/mode.js +52 -0
  14. package/dist/auth/next-path.d.ts +1 -0
  15. package/dist/auth/next-path.js +10 -0
  16. package/dist/auth/plaza.d.ts +68 -0
  17. package/dist/auth/plaza.js +154 -0
  18. package/dist/auth/problems.d.ts +4 -0
  19. package/dist/auth/problems.js +36 -0
  20. package/dist/auth/users.d.ts +8 -0
  21. package/dist/auth/users.js +25 -0
  22. package/dist/brand.generated.d.ts +2 -0
  23. package/dist/brand.generated.js +3 -0
  24. package/dist/chat/paths.d.ts +10 -0
  25. package/dist/chat/paths.js +10 -0
  26. package/dist/chat/server.d.ts +24 -0
  27. package/dist/chat/server.js +161 -0
  28. package/dist/client.d.ts +3 -3
  29. package/dist/client.js +2 -2
  30. package/dist/db/cli.js +3 -40
  31. package/dist/db/migrations.d.ts +2 -2
  32. package/dist/db/migrations.js +2 -2
  33. package/dist/invoke/contracts.d.ts +28 -0
  34. package/dist/invoke/contracts.js +1 -0
  35. package/dist/invoke/error.d.ts +11 -0
  36. package/dist/invoke/error.js +13 -0
  37. package/dist/invoke/index.d.ts +73 -0
  38. package/dist/invoke/index.js +274 -0
  39. package/dist/invoke/paths.d.ts +2 -0
  40. package/dist/invoke/paths.js +2 -0
  41. package/dist/invoke/server.d.ts +45 -0
  42. package/dist/invoke/server.js +259 -0
  43. package/dist/platform/index.d.ts +71 -0
  44. package/dist/platform/index.js +80 -0
  45. package/dist/platform/pane-bridge.d.ts +1 -0
  46. package/dist/platform/pane-bridge.js +216 -0
  47. package/dist/platform/server-fn-door.d.ts +27 -0
  48. package/dist/platform/server-fn-door.js +32 -0
  49. package/dist/platform.d.ts +2 -2
  50. package/dist/platform.js +3 -3
  51. package/dist/request-error.d.ts +10 -0
  52. package/dist/request-error.js +29 -0
  53. package/dist/schema.d.ts +1 -1
  54. package/dist/server/db-retry.d.ts +7 -0
  55. package/dist/server/db-retry.js +58 -0
  56. package/dist/server/entities.js +1 -1
  57. package/dist/server/health.d.ts +17 -0
  58. package/dist/server/health.js +42 -0
  59. package/dist/server/index.d.ts +7 -6
  60. package/dist/server/index.js +7 -6
  61. package/dist/server/sentry-tunnel.d.ts +6 -0
  62. package/dist/server/sentry-tunnel.js +52 -0
  63. package/dist/server/session.d.ts +1 -1
  64. package/dist/server/session.js +2 -2
  65. package/dist/uuid.d.ts +1 -0
  66. package/dist/uuid.js +7 -0
  67. package/package.json +12 -10
  68. package/dist/db/baseline.d.ts +0 -36
  69. package/dist/db/baseline.js +0 -439
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  The platform half of a v2 org app (`infra/provisioning/org-app-template-v2`):
4
4
  its tables and their typed client, its door, its contracts and its chat.
5
- Sign-in, contracts and chat are `@shubh90/app-runtime`'s, re-exported
6
- unchanged; entities and the schema sync are new here.
5
+ `src/brand.generated.ts` is baked from `config/brand.json` (`pnpm run brand`;
6
+ `check` fails while it is stale), since the published package cannot read it.
7
7
 
8
8
  | Import | What |
9
9
  |---|---|
@@ -12,7 +12,7 @@ unchanged; entities and the schema sync are new here.
12
12
  | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
13
  | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
- | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts; `baseline`: an older app's tables into `db/schema`, once |
15
+ | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
16
16
 
17
17
  ## Entities
18
18
 
@@ -89,21 +89,6 @@ table` through `mii db write`) is not archived.
89
89
  `migrate` installs the functions before applying migrations and the triggers
90
90
  after; the template's AGENTS.md has the restore SQL.
91
91
 
92
- ## Baseline
93
-
94
- `light-db baseline` moves an app made before `db/schema` (the Vite or Next.js
95
- template, tables made by `migrations/*.sql`) onto it, once: `drizzle-kit pull`
96
- reads the live database, and `db/migrations/0000_baseline.sql` records the
97
- tables as they are (its SQL commented out, so migrating runs nothing). A table
98
- keyed by `id uuid` whose standard columns match becomes an `entity()` file;
99
- any other stays the plain `pgTable` pull wrote, which no sync changes. The old
100
- templates' own tables are left in the database. Before writing, it checks the
101
- files say exactly what the database has; the next sync then only adds the
102
- `entity()` columns a table lacks and the SDK's `light_*` tables. It refuses,
103
- writing nothing, a foreign key into a table it leaves out, or an index with a
104
- non-default operator class. It reads the tables' keys, which Postgres shows
105
- only to a role with more than SELECT: an app's reader has REFERENCES for it.
106
-
107
92
  ## Develop
108
93
 
109
94
  ```bash
@@ -0,0 +1,5 @@
1
+ export type MiiAuthConfig = {
2
+ readonly plazaUrl: string;
3
+ readonly appId: string;
4
+ };
5
+ export declare function miiAuthConfig(): MiiAuthConfig;
@@ -0,0 +1,16 @@
1
+ // Which app this is and which platform it trusts. Both are injected by the
2
+ // platform (Vercel env + the agent checkout's .env.local) — never set them by
3
+ // hand. MII_ORG_ID is injected too, for the app's own use; sign-in needs only
4
+ // these two.
5
+ import { z } from "zod/v4";
6
+ const env = z.object({
7
+ MII_PLAZA_URL: z.string().trim().min(1),
8
+ MII_APP_ID: z.string().trim().min(1)
9
+ });
10
+ export function miiAuthConfig() {
11
+ const set = env.safeParse(process.env);
12
+ if (!set.success) {
13
+ throw new Error("MII_PLAZA_URL and MII_APP_ID are required for sign-in. They are injected at provision time; run `vercel env pull .env.local` locally.");
14
+ }
15
+ return { plazaUrl: set.data.MII_PLAZA_URL.replace(/\/+$/, ""), appId: set.data.MII_APP_ID };
16
+ }
@@ -0,0 +1,11 @@
1
+ import type { AuthSql } from "./db.js";
2
+ /**
3
+ * The per-app binding the db-touching helpers close over: the connection
4
+ * factory. Built once by `createMiiAuthCore` and threaded in, so nothing here
5
+ * reads a module-level singleton.
6
+ */
7
+ export type AuthContext = {
8
+ readonly getSql: () => Promise<AuthSql>;
9
+ /** Where the app wants failures that are ours reported (Sentry, etc.). */
10
+ readonly reportError: (error: unknown, context: Record<string, string>) => void;
11
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ import { getAuthMode } from "./mode.js";
2
+ import { safeNext } from "./next-path.js";
3
+ import { SIGN_IN_PROBLEMS } from "./problems.js";
4
+ import { type MiiUser, type ResolvedSession } from "./lookup.js";
5
+ import { type MiiIdentity, type PlazaSession } from "./plaza.js";
6
+ export type { MiiUser, ResolvedSession } from "./lookup.js";
7
+ export type { AuthMode } from "./mode.js";
8
+ export type { MiiIdentity, PlazaSession, SignedIn, SignInFailure, SignInResult } from "./plaza.js";
9
+ export { SIGN_IN_PROBLEMS } from "./problems.js";
10
+ export { safeNext } from "./next-path.js";
11
+ export { SESSION_COOKIE } from "./lookup.js";
12
+ export { signInWithPlaza } from "./plaza.js";
13
+ export type CreateMiiAuthCoreOptions = {
14
+ /** Where to report failures that are ours, not the person's. Defaults to console.error. */
15
+ readonly reportError?: (error: unknown, context: Record<string, string>) => void;
16
+ };
17
+ export type MiiAuthCore = {
18
+ /** The user behind a session token, or null. The app reads its own cookie. */
19
+ resolveSession(token: string): Promise<MiiUser | null>;
20
+ /** The same, with the platform token behind the session, for acting as the person. */
21
+ resolveSessionWithPlaza(token: string): Promise<ResolvedSession | null>;
22
+ /**
23
+ * Start a session for the session the platform signed at sign-in; the app sets the
24
+ * cookie itself (see cookie notes below).
25
+ */
26
+ issueSession(userId: string, plaza: PlazaSession): Promise<{
27
+ token: string;
28
+ expiresAt: Date;
29
+ }>;
30
+ /** End one session by its token. */
31
+ revokeSession(token: string): Promise<void>;
32
+ upsertMemberFromIdentity(identity: MiiIdentity): Promise<{
33
+ id: string;
34
+ status: string;
35
+ }>;
36
+ deactivateUser(userId: string): Promise<void>;
37
+ /**
38
+ * The pane's silent sign-in: spend a ?mii_code= for an identity, upsert the
39
+ * member, start a session. Null when the code is spent/stale or the member
40
+ * is inactive (send them to /login).
41
+ */
42
+ redeemPaneCode(code: string): Promise<{
43
+ token: string;
44
+ expiresAt: Date;
45
+ } | null>;
46
+ getAuthMode: typeof getAuthMode;
47
+ readonly SESSION_COOKIE: string;
48
+ readonly SIGN_IN_PROBLEMS: typeof SIGN_IN_PROBLEMS;
49
+ readonly safeNext: typeof safeNext;
50
+ };
51
+ /**
52
+ * Cookie attributes the app must use with issueSession's token: inside the platform's App pane the app is a third party, so the
53
+ * cookie must be SameSite=None + Secure + partitioned or the pane signs in
54
+ * forever; opened directly it is an ordinary Lax cookie. Decide by the
55
+ * request's `Sec-Fetch-Dest: iframe` header.
56
+ */
57
+ export declare function cookieAttributes(embedded: boolean): {
58
+ httpOnly: true;
59
+ path: "/";
60
+ sameSite: "none" | "lax";
61
+ secure: boolean;
62
+ partitioned: boolean;
63
+ };
64
+ export declare function createMiiAuthCore({ reportError }?: CreateMiiAuthCoreOptions): MiiAuthCore;
@@ -0,0 +1,50 @@
1
+ import { makeAuthSql } from "./db.js";
2
+ import { getAuthMode } from "./mode.js";
3
+ import { safeNext } from "./next-path.js";
4
+ import { SIGN_IN_PROBLEMS } from "./problems.js";
5
+ import { issueSession, resolveSession, resolveSessionWithPlaza, revokeSession, SESSION_COOKIE } from "./lookup.js";
6
+ import { redeemWithPlaza } from "./plaza.js";
7
+ import { deactivateUser, upsertMemberFromIdentity } from "./users.js";
8
+ export { SIGN_IN_PROBLEMS } from "./problems.js";
9
+ export { safeNext } from "./next-path.js";
10
+ export { SESSION_COOKIE } from "./lookup.js";
11
+ export { signInWithPlaza } from "./plaza.js";
12
+ /**
13
+ * Cookie attributes the app must use with issueSession's token: inside the platform's App pane the app is a third party, so the
14
+ * cookie must be SameSite=None + Secure + partitioned or the pane signs in
15
+ * forever; opened directly it is an ordinary Lax cookie. Decide by the
16
+ * request's `Sec-Fetch-Dest: iframe` header.
17
+ */
18
+ export function cookieAttributes(embedded) {
19
+ return {
20
+ httpOnly: true,
21
+ path: "/",
22
+ sameSite: embedded ? "none" : "lax",
23
+ secure: embedded || process.env.NODE_ENV === "production",
24
+ partitioned: embedded
25
+ };
26
+ }
27
+ export function createMiiAuthCore({ reportError = (error, context) => console.error("sign-in error", context, error) } = {}) {
28
+ const ctx = { getSql: makeAuthSql(), reportError };
29
+ return {
30
+ resolveSession: (token) => resolveSession(ctx, token),
31
+ resolveSessionWithPlaza: (token) => resolveSessionWithPlaza(ctx, token),
32
+ issueSession: (userId, plaza) => issueSession(ctx, userId, plaza),
33
+ revokeSession: (token) => revokeSession(ctx, token),
34
+ upsertMemberFromIdentity: (identity) => upsertMemberFromIdentity(ctx, identity),
35
+ deactivateUser: (userId) => deactivateUser(ctx, userId),
36
+ redeemPaneCode: async (code) => {
37
+ const signedIn = await redeemWithPlaza(code);
38
+ if (signedIn === null)
39
+ return null;
40
+ const user = await upsertMemberFromIdentity(ctx, signedIn.identity);
41
+ if (user.status !== "active")
42
+ return null;
43
+ return issueSession(ctx, user.id, signedIn.session);
44
+ },
45
+ getAuthMode,
46
+ SESSION_COOKIE,
47
+ SIGN_IN_PROBLEMS,
48
+ safeNext
49
+ };
50
+ }
@@ -0,0 +1,7 @@
1
+ import postgres from "postgres";
2
+ export type AuthSql = ReturnType<typeof postgres>;
3
+ /**
4
+ * A connection factory. Memoizes a single pool per process; a failed init (database briefly unreachable) does not wedge
5
+ * sign-in for the life of the process — the next request tries again.
6
+ */
7
+ export declare function makeAuthSql(): () => Promise<AuthSql>;
@@ -0,0 +1,57 @@
1
+ // Sign-in's own database access, on the `postgres` driver. Its tables are
2
+ // created on first use, so every app's database has them the first time
3
+ // someone signs in.
4
+ import postgres from "postgres";
5
+ // Idempotent, so a database that already has the tables is untouched.
6
+ const DDL = [
7
+ `create table if not exists mii_auth_users (
8
+ id uuid primary key default gen_random_uuid(),
9
+ mii_id uuid unique,
10
+ email text,
11
+ name text not null,
12
+ avatar_url text,
13
+ kind text not null default 'member',
14
+ role text not null default 'member',
15
+ status text not null default 'active',
16
+ created_at timestamptz not null default now(),
17
+ last_login_at timestamptz
18
+ )`,
19
+ `create index if not exists mii_auth_users_email_idx on mii_auth_users (email)`,
20
+ // `kind` marks the org's agents apart from its people; older tables lack it.
21
+ `alter table mii_auth_users add column if not exists kind text not null default 'member'`,
22
+ `create table if not exists mii_auth_sessions (
23
+ token_hash text primary key,
24
+ user_id uuid not null references mii_auth_users(id) on delete cascade,
25
+ expires_at timestamptz not null,
26
+ created_at timestamptz not null default now()
27
+ )`,
28
+ `create index if not exists mii_auth_sessions_user_idx on mii_auth_sessions (user_id)`,
29
+ // The platform's side of a session; older tables lack them.
30
+ `alter table mii_auth_sessions add column if not exists plaza_token text`,
31
+ `alter table mii_auth_sessions add column if not exists checked_at timestamptz`
32
+ ];
33
+ /**
34
+ * A connection factory. Memoizes a single pool per process; a failed init (database briefly unreachable) does not wedge
35
+ * sign-in for the life of the process — the next request tries again.
36
+ */
37
+ export function makeAuthSql() {
38
+ let ready = null;
39
+ return () => {
40
+ if (ready === null) {
41
+ ready = (async () => {
42
+ const url = process.env.DATABASE_URL;
43
+ if (!url)
44
+ throw new Error("DATABASE_URL is required for sign-in.");
45
+ const client = postgres(url, { prepare: false, max: 2 });
46
+ for (const statement of DDL) {
47
+ await client.unsafe(statement);
48
+ }
49
+ return client;
50
+ })();
51
+ ready.catch(() => {
52
+ ready = null;
53
+ });
54
+ }
55
+ return ready;
56
+ };
57
+ }
@@ -0,0 +1,47 @@
1
+ import type { AuthContext } from "./context.js";
2
+ import { type PlazaSession } from "./plaza.js";
3
+ /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
+ export declare const SESSION_COOKIE = "mii_session";
5
+ /**
6
+ * How often a session asks the platform whether it still stands. Someone removed from
7
+ * the org is signed out of the app within this long; their chat stops at once,
8
+ * because the platform checks membership on every chat request.
9
+ */
10
+ export declare const SESSION_RECHECK_MS: number;
11
+ export type MiiUser = {
12
+ readonly id: string;
13
+ readonly miiId: string | null;
14
+ readonly name: string;
15
+ readonly email: string | null;
16
+ readonly avatarUrl: string | null;
17
+ readonly role: string;
18
+ };
19
+ /** A session with the platform token behind it, for acting on the platform as the person. */
20
+ export type ResolvedSession = {
21
+ readonly user: MiiUser;
22
+ readonly plazaToken: string;
23
+ };
24
+ /**
25
+ * The user behind a session token, or null. Pure lookup — no request context —
26
+ * so the proxy can call it with the raw cookie value. A missing, expired, or
27
+ * deactivated session is null, and so is one the platform no longer stands behind.
28
+ */
29
+ export declare function resolveSession(ctx: AuthContext, token: string): Promise<MiiUser | null>;
30
+ export declare function resolveSessionWithPlaza(ctx: AuthContext, token: string): Promise<ResolvedSession | null>;
31
+ /**
32
+ * Start a session and hand back the cookie value to set. It lasts exactly as
33
+ * long as the session the platform signed. Only the cookie's hash is stored, so a
34
+ * database read cannot be replayed as a sign-in here; the platform's token is kept as
35
+ * it is, because this server has to send it, and signing out revokes it.
36
+ */
37
+ export declare function issueSession(ctx: AuthContext, userId: string, plaza: PlazaSession): Promise<{
38
+ token: string;
39
+ expiresAt: Date;
40
+ }>;
41
+ /**
42
+ * End one session by its cookie value, here and on the platform. The session here ends
43
+ * whatever the platform says: an unreachable platform is reported, and signing
44
+ * out still signs out.
45
+ */
46
+ export declare function revokeSession(ctx: AuthContext, token: string): Promise<void>;
47
+ export declare function hashToken(token: string): string;
@@ -0,0 +1,120 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { checkPlazaSession, revokePlazaSession } from "./plaza.js";
3
+ /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
+ export const SESSION_COOKIE = "mii_session";
5
+ /**
6
+ * How often a session asks the platform whether it still stands. Someone removed from
7
+ * the org is signed out of the app within this long; their chat stops at once,
8
+ * because the platform checks membership on every chat request.
9
+ */
10
+ export const SESSION_RECHECK_MS = 5 * 60 * 1000;
11
+ /**
12
+ * The user behind a session token, or null. Pure lookup — no request context —
13
+ * so the proxy can call it with the raw cookie value. A missing, expired, or
14
+ * deactivated session is null, and so is one the platform no longer stands behind.
15
+ */
16
+ export async function resolveSession(ctx, token) {
17
+ const session = await resolveSessionWithPlaza(ctx, token);
18
+ return session === null ? null : session.user;
19
+ }
20
+ export async function resolveSessionWithPlaza(ctx, token) {
21
+ const sql = await ctx.getSql();
22
+ const tokenHash = hashToken(token);
23
+ const rows = await sql `
24
+ select u.id, u.mii_id, u.name, u.email, u.avatar_url, u.role,
25
+ s.plaza_token,
26
+ (s.checked_at is null or s.checked_at < now() - ${`${SESSION_RECHECK_MS} milliseconds`}::interval) as due
27
+ from mii_auth_sessions s
28
+ join mii_auth_users u on u.id = s.user_id
29
+ where s.token_hash = ${tokenHash}
30
+ and s.expires_at > now()
31
+ and s.plaza_token is not null
32
+ and u.status = 'active'
33
+ limit 1`;
34
+ const [row = null] = rows;
35
+ if (row === null)
36
+ return null;
37
+ if (row.due) {
38
+ const stands = await stillStands(ctx, tokenHash, row.plaza_token);
39
+ if (!stands) {
40
+ return null;
41
+ }
42
+ }
43
+ return {
44
+ user: {
45
+ id: row.id,
46
+ miiId: row.mii_id,
47
+ name: row.name,
48
+ email: row.email,
49
+ avatarUrl: row.avatar_url,
50
+ role: row.role
51
+ },
52
+ plazaToken: row.plaza_token
53
+ };
54
+ }
55
+ /**
56
+ * Ask the platform whether a session still stands, once per recheck window however
57
+ * many requests arrive together: the first to claim the window asks, the rest
58
+ * go on the answer it last had. A session the platform has ended is deleted. The platform
59
+ * being unreachable is reported and the session kept — an outage there must not
60
+ * sign every app's people out.
61
+ */
62
+ async function stillStands(ctx, tokenHash, plazaToken) {
63
+ const sql = await ctx.getSql();
64
+ const claimed = await sql `
65
+ update mii_auth_sessions set checked_at = now()
66
+ where token_hash = ${tokenHash}
67
+ and (checked_at is null or checked_at < now() - ${`${SESSION_RECHECK_MS} milliseconds`}::interval)
68
+ returning token_hash`;
69
+ if (claimed.length === 0)
70
+ return true;
71
+ let answer;
72
+ try {
73
+ answer = await checkPlazaSession(plazaToken);
74
+ }
75
+ catch (error) {
76
+ ctx.reportError(error, { where: "sign-in/session", step: "recheck" });
77
+ return true;
78
+ }
79
+ if (answer === "ended") {
80
+ await sql `delete from mii_auth_sessions where token_hash = ${tokenHash}`;
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+ /**
86
+ * Start a session and hand back the cookie value to set. It lasts exactly as
87
+ * long as the session the platform signed. Only the cookie's hash is stored, so a
88
+ * database read cannot be replayed as a sign-in here; the platform's token is kept as
89
+ * it is, because this server has to send it, and signing out revokes it.
90
+ */
91
+ export async function issueSession(ctx, userId, plaza) {
92
+ const token = randomBytes(32).toString("base64url");
93
+ const sql = await ctx.getSql();
94
+ await sql `
95
+ insert into mii_auth_sessions (token_hash, user_id, expires_at, plaza_token, checked_at)
96
+ values (${hashToken(token)}, ${userId}, ${plaza.expiresAt}, ${plaza.token}, now())`;
97
+ return { token, expiresAt: plaza.expiresAt };
98
+ }
99
+ /**
100
+ * End one session by its cookie value, here and on the platform. The session here ends
101
+ * whatever the platform says: an unreachable platform is reported, and signing
102
+ * out still signs out.
103
+ */
104
+ export async function revokeSession(ctx, token) {
105
+ const sql = await ctx.getSql();
106
+ const [ended = null] = await sql `
107
+ delete from mii_auth_sessions where token_hash = ${hashToken(token)}
108
+ returning plaza_token`;
109
+ if (ended === null || ended.plaza_token === null)
110
+ return;
111
+ try {
112
+ await revokePlazaSession(ended.plaza_token);
113
+ }
114
+ catch (error) {
115
+ ctx.reportError(error, { where: "sign-in/session", step: "revoke" });
116
+ }
117
+ }
118
+ export function hashToken(token) {
119
+ return createHash("sha256").update(token).digest("hex");
120
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from "zod/v4";
2
+ declare const authMode: z.ZodEnum<{
3
+ public: "public";
4
+ members: "members";
5
+ }>;
6
+ export type AuthMode = z.infer<typeof authMode>;
7
+ export declare function getAuthMode(): Promise<AuthMode>;
8
+ export {};
@@ -0,0 +1,52 @@
1
+ import { z } from "zod/v4";
2
+ import { miiAuthConfig } from "./config.js";
3
+ // Who may open this app, as set in the platform (Settings → Workspaces → "Who can open
4
+ // it"). Read from the platform rather than baked into the build so the switch takes
5
+ // effect on the next request, not the next deploy.
6
+ //
7
+ // `public` — no door; every visitor is anonymous.
8
+ // `members` — only people in the org, who sign in through the platform.
9
+ const authMode = z.enum(["public", "members"]);
10
+ /** What the platform answers about the app: who may open it. */
11
+ const appAuthConfig = z.object({ authMode });
12
+ const TTL_MS = 30_000;
13
+ let cached = null;
14
+ let inflight = null;
15
+ export async function getAuthMode() {
16
+ if (cached === null || Date.now() - cached.fetchedAt >= TTL_MS) {
17
+ if (inflight === null) {
18
+ inflight = fetchMode()
19
+ .then((mode) => {
20
+ cached = { mode, fetchedAt: Date.now() };
21
+ })
22
+ .catch((error) => {
23
+ // Keep serving the last known answer through a blip. Only a cold start
24
+ // with the platform unreachable has nothing to fall back to, and there the door
25
+ // stays shut rather than swinging open.
26
+ if (cached === null)
27
+ throw error;
28
+ cached = { ...cached, fetchedAt: Date.now() };
29
+ })
30
+ .finally(() => {
31
+ inflight = null;
32
+ });
33
+ }
34
+ await inflight;
35
+ }
36
+ if (cached === null) {
37
+ throw new Error("Could not reach the platform to check who may open this app.");
38
+ }
39
+ return cached.mode;
40
+ }
41
+ async function fetchMode() {
42
+ const { plazaUrl, appId } = miiAuthConfig();
43
+ const response = await fetch(`${plazaUrl}/api/app-auth/config?app_id=${encodeURIComponent(appId)}`, { cache: "no-store" });
44
+ if (!response.ok) {
45
+ throw new Error(`The platform's app-auth config answered ${response.status}.`);
46
+ }
47
+ const config = appAuthConfig.safeParse(await response.json());
48
+ if (!config.success) {
49
+ throw new Error("The platform's app-auth config answered with an unexpected shape.");
50
+ }
51
+ return config.data.authMode;
52
+ }
@@ -0,0 +1 @@
1
+ export declare function safeNext(candidate: string | null): string;
@@ -0,0 +1,10 @@
1
+ // Only a path on this app is a valid place to land after signing in. Anything
2
+ // absolute (or protocol-relative) would make the login flow an open redirect.
3
+ export function safeNext(candidate) {
4
+ if (candidate === null ||
5
+ !candidate.startsWith("/") ||
6
+ candidate.startsWith("//")) {
7
+ return "/";
8
+ }
9
+ return candidate;
10
+ }
@@ -0,0 +1,68 @@
1
+ /** Who someone is, once the platform has vouched for them. */
2
+ export type MiiIdentity = {
3
+ readonly miiId: string;
4
+ readonly orgId: string;
5
+ readonly email: string | null;
6
+ readonly name: string;
7
+ readonly avatarUrl: string | null;
8
+ /** One of the org's agents, signed in to check the app: not one of its people. */
9
+ readonly isAgent: boolean;
10
+ };
11
+ /**
12
+ * The session the platform signs for someone it just signed in. This app keeps the
13
+ * token on its own session, ends that session when the token expires, and sends
14
+ * the token back whenever it acts for the person (their chat), so the platform can
15
+ * check they are still in the org.
16
+ */
17
+ export type PlazaSession = {
18
+ readonly token: string;
19
+ readonly expiresAt: Date;
20
+ };
21
+ /**
22
+ * Why a sign-in did not happen, in words this app can show as they are.
23
+ *
24
+ * `cause` rides along on the failures that are ours rather than the person's
25
+ * (the platform unreachable, the platform answering nonsense), so the route can report them.
26
+ */
27
+ export type SignInFailure = {
28
+ readonly kind: "credentials" | "not_a_member" | "throttled";
29
+ readonly message: string;
30
+ } | {
31
+ readonly kind: "unavailable";
32
+ readonly message: string;
33
+ readonly cause: unknown;
34
+ };
35
+ export type SignedIn = {
36
+ readonly identity: MiiIdentity;
37
+ readonly session: PlazaSession;
38
+ };
39
+ export type SignInResult = ({
40
+ readonly ok: true;
41
+ } & SignedIn) | {
42
+ readonly ok: false;
43
+ readonly failure: SignInFailure;
44
+ };
45
+ /**
46
+ * Check an email and password with the platform, and get back who the person is and
47
+ * the session the platform signed for them.
48
+ */
49
+ export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
50
+ /**
51
+ * Spend a code for the person behind it, or null if it is spent or stale.
52
+ *
53
+ * Throws when the platform itself is the problem — a 5xx or an unreadable answer — so
54
+ * that reaches the route's error reporting rather than looking like a stale
55
+ * code and sending the person round again.
56
+ */
57
+ export declare function redeemWithPlaza(code: string): Promise<SignedIn | null>;
58
+ /**
59
+ * Whether the platform still stands behind a session: "ended" when the token is no
60
+ * longer good or the person has left the org. Throws when the platform cannot say, so
61
+ * an outage is reported rather than read as either answer.
62
+ */
63
+ export declare function checkPlazaSession(token: string): Promise<"valid" | "ended">;
64
+ /**
65
+ * Tell the platform a session is over, so the token stops working now rather than when
66
+ * it expires. Throws when the platform did not take it.
67
+ */
68
+ export declare function revokePlazaSession(token: string): Promise<void>;