@shubh90/app-runtime 0.4.0 → 0.5.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.
Files changed (54) hide show
  1. package/README.md +13 -4
  2. package/dist/auth/core.d.ts +12 -7
  3. package/dist/auth/core.js +9 -11
  4. package/dist/auth/db.js +10 -0
  5. package/dist/auth/index.d.ts +4 -4
  6. package/dist/auth/index.js +6 -6
  7. package/dist/auth/lookup.d.ts +23 -12
  8. package/dist/auth/lookup.js +85 -24
  9. package/dist/auth/plaza.d.ts +30 -10
  10. package/dist/auth/plaza.js +65 -21
  11. package/dist/auth/routes.js +1 -1
  12. package/dist/auth/session.d.ts +3 -2
  13. package/dist/auth/session.js +5 -6
  14. package/dist/chat/index.d.ts +8 -0
  15. package/dist/chat/index.js +107 -0
  16. package/dist/chat/paths.d.ts +2 -0
  17. package/dist/chat/paths.js +2 -0
  18. package/dist/chat/server.d.ts +16 -0
  19. package/dist/chat/server.js +167 -0
  20. package/dist/chat/thread.d.ts +27 -0
  21. package/dist/chat/thread.js +59 -0
  22. package/dist/chat/ui/activity.d.ts +12 -0
  23. package/dist/chat/ui/activity.js +57 -0
  24. package/dist/chat/ui/attachments.d.ts +9 -0
  25. package/dist/chat/ui/attachments.js +50 -0
  26. package/dist/chat/ui/index.d.ts +5 -0
  27. package/dist/chat/ui/index.js +6 -0
  28. package/dist/chat/ui/message-body.d.ts +8 -0
  29. package/dist/chat/ui/message-body.js +57 -0
  30. package/dist/chat/ui/styles.css +109 -0
  31. package/dist/chat/ui/styles.d.ts +16 -0
  32. package/dist/chat/ui/styles.js +126 -0
  33. package/dist/chat/ui/types.d.ts +47 -0
  34. package/dist/chat/ui/types.js +1 -0
  35. package/dist/chat/widget/api.d.ts +34 -0
  36. package/dist/chat/widget/api.js +112 -0
  37. package/dist/chat/widget/app.d.ts +11 -0
  38. package/dist/chat/widget/app.js +304 -0
  39. package/dist/chat/widget/hooks.d.ts +86 -0
  40. package/dist/chat/widget/hooks.js +312 -0
  41. package/dist/chat/widget/icons.d.ts +13 -0
  42. package/dist/chat/widget/icons.js +13 -0
  43. package/dist/chat/widget/styles.d.ts +1 -0
  44. package/dist/chat/widget/styles.js +233 -0
  45. package/dist/chat/widget/thread.d.ts +33 -0
  46. package/dist/chat/widget/thread.js +27 -0
  47. package/dist/platform/index.d.ts +2 -1
  48. package/dist/server/build-marker.d.ts +10 -0
  49. package/dist/server/build-marker.js +51 -0
  50. package/dist/server/index.d.ts +2 -0
  51. package/dist/server/index.js +2 -0
  52. package/dist/server/sentry-tunnel.d.ts +6 -0
  53. package/dist/server/sentry-tunnel.js +43 -0
  54. package/package.json +50 -11
package/README.md CHANGED
@@ -29,11 +29,20 @@ and autosave never fights a kit-dirtied tree. Apps pin a version; a breaking
29
29
  Plaza contract change is a semver major with a deprecation window, not a flag
30
30
  day.
31
31
 
32
- ## Publishing (GitHub Packages)
32
+ ## Publishing (npmjs, public)
33
33
 
34
- Built and published from the monorepo on a `app-runtime-v*` tag by
35
- `.github/workflows/publish-app-runtime.yml`. The registry is
36
- `npm.pkg.github.com`, scope `@mii`.
34
+ Every released version (0.1.0 →) lives on the PUBLIC npm registry — that is
35
+ where app installs resolve, with no auth to provision in any build
36
+ environment, which is the point. Publish from a machine logged in as
37
+ `shubh90`:
38
+
39
+ ```bash
40
+ # from packages/app-runtime, after npm run check && npm run build:
41
+ npm publish --registry=https://registry.npmjs.org
42
+ ```
43
+
44
+ The `app-runtime-v*` tag workflow publishes a mirror copy to GitHub Packages;
45
+ nothing installs from there today, so treat npmjs as the source of truth.
37
46
 
38
47
  ```bash
39
48
  # maintainers, from packages/app-runtime:
@@ -1,17 +1,17 @@
1
1
  import { getAuthMode } from "./mode.js";
2
2
  import { safeNext } from "./next-path.js";
3
3
  import { SIGN_IN_PROBLEMS } from "./problems.js";
4
- import { type MiiUser } from "./lookup.js";
5
- import { type MiiIdentity } from "./plaza.js";
4
+ import { type MiiUser, type ResolvedSession } from "./lookup.js";
5
+ import { type MiiIdentity, type PlazaSession } from "./plaza.js";
6
6
  import { type UsersConfig } from "./users-config.js";
7
- export type { MiiUser } from "./lookup.js";
7
+ export type { MiiUser, ResolvedSession } from "./lookup.js";
8
8
  export type { AuthMode } from "./mode.js";
9
- export type { MiiIdentity, SignInFailure, SignInResult } from "./plaza.js";
9
+ export type { MiiIdentity, PlazaSession, SignedIn, SignInFailure, SignInResult } from "./plaza.js";
10
10
  export type { UsersConfig } from "./users-config.js";
11
11
  export { SIGN_IN_PROBLEMS } from "./problems.js";
12
12
  export { safeNext } from "./next-path.js";
13
13
  export { getAuthMode } from "./mode.js";
14
- export { SESSION_COOKIE, SESSION_TTL_MS } from "./lookup.js";
14
+ export { SESSION_COOKIE, SESSION_RECHECK_MS } from "./lookup.js";
15
15
  export { miiAuthConfig, type MiiAuthConfig } from "./config.js";
16
16
  export { redeemWithPlaza, signInWithPlaza } from "./plaza.js";
17
17
  export type CreateMiiAuthCoreOptions = {
@@ -23,8 +23,13 @@ export type CreateMiiAuthCoreOptions = {
23
23
  export type MiiAuthCore = {
24
24
  /** The user behind a session token, or null. The app reads its own cookie. */
25
25
  resolveSession(token: string): Promise<MiiUser | null>;
26
- /** Start a session; the app sets the cookie itself (see cookie notes below). */
27
- issueSession(userId: string): Promise<{
26
+ /** The same, with the Plaza token behind the session, for acting as the person. */
27
+ resolveSessionWithPlaza(token: string): Promise<ResolvedSession | null>;
28
+ /**
29
+ * Start a session for the session Plaza signed at sign-in; the app sets the
30
+ * cookie itself (see cookie notes below).
31
+ */
32
+ issueSession(userId: string, plaza: PlazaSession): Promise<{
28
33
  token: string;
29
34
  expiresAt: Date;
30
35
  }>;
package/dist/auth/core.js CHANGED
@@ -2,14 +2,14 @@ import { makeAuthSql } from "./db.js";
2
2
  import { getAuthMode } from "./mode.js";
3
3
  import { safeNext } from "./next-path.js";
4
4
  import { SIGN_IN_PROBLEMS } from "./problems.js";
5
- import { issueSession, resolveSession, SESSION_COOKIE, SESSION_TTL_MS, hashToken } from "./lookup.js";
5
+ import { issueSession, resolveSession, resolveSessionWithPlaza, revokeSession, SESSION_COOKIE } from "./lookup.js";
6
6
  import { redeemWithPlaza } from "./plaza.js";
7
7
  import { deactivateUser, upsertMemberFromIdentity } from "./users.js";
8
8
  import { resolveUsers } from "./users-config.js";
9
9
  export { SIGN_IN_PROBLEMS } from "./problems.js";
10
10
  export { safeNext } from "./next-path.js";
11
11
  export { getAuthMode } from "./mode.js";
12
- export { SESSION_COOKIE, SESSION_TTL_MS } from "./lookup.js";
12
+ export { SESSION_COOKIE, SESSION_RECHECK_MS } from "./lookup.js";
13
13
  export { miiAuthConfig } from "./config.js";
14
14
  export { redeemWithPlaza, signInWithPlaza } from "./plaza.js";
15
15
  /**
@@ -38,21 +38,19 @@ export function createMiiAuthCore(options = {}) {
38
38
  };
39
39
  return {
40
40
  resolveSession: (token) => resolveSession(ctx, token),
41
- issueSession: (userId) => issueSession(ctx, userId),
42
- revokeSession: async (token) => {
43
- const sql = await ctx.getSql();
44
- await sql `delete from mii_auth_sessions where token_hash = ${hashToken(token)}`;
45
- },
41
+ resolveSessionWithPlaza: (token) => resolveSessionWithPlaza(ctx, token),
42
+ issueSession: (userId, plaza) => issueSession(ctx, userId, plaza),
43
+ revokeSession: (token) => revokeSession(ctx, token),
46
44
  upsertMemberFromIdentity: (identity) => upsertMemberFromIdentity(ctx, identity),
47
45
  deactivateUser: (userId) => deactivateUser(ctx, userId),
48
46
  redeemPaneCode: async (code) => {
49
- const identity = await redeemWithPlaza(code);
50
- if (identity === null)
47
+ const signedIn = await redeemWithPlaza(code);
48
+ if (signedIn === null)
51
49
  return null;
52
- const user = await upsertMemberFromIdentity(ctx, identity);
50
+ const user = await upsertMemberFromIdentity(ctx, signedIn.identity);
53
51
  if (user.status !== "active")
54
52
  return null;
55
- return issueSession(ctx, user.id);
53
+ return issueSession(ctx, user.id, signedIn.session);
56
54
  },
57
55
  getAuthMode,
58
56
  SESSION_COOKIE,
package/dist/auth/db.js CHANGED
@@ -32,6 +32,13 @@ const DDL = [
32
32
  )`,
33
33
  `create index if not exists mii_auth_sessions_user_idx on mii_auth_sessions (user_id)`
34
34
  ];
35
+ // The session columns that carry Plaza's side of a session. Run whatever the
36
+ // users table: the sessions table is always the kit's, and a session without
37
+ // them cannot be resolved (see lookup.ts).
38
+ const SESSION_DDL = [
39
+ `alter table if exists mii_auth_sessions add column if not exists plaza_token text`,
40
+ `alter table if exists mii_auth_sessions add column if not exists checked_at timestamptz`
41
+ ];
35
42
  /**
36
43
  * A connection factory bound to one app's user config. Memoizes a single pool
37
44
  * per process; a failed init (database briefly unreachable) does not wedge
@@ -51,6 +58,9 @@ export function makeAuthSql(users) {
51
58
  await client.unsafe(statement);
52
59
  }
53
60
  }
61
+ for (const statement of SESSION_DDL) {
62
+ await client.unsafe(statement);
63
+ }
54
64
  return client;
55
65
  })();
56
66
  ready.catch(() => {
@@ -2,11 +2,11 @@ import { getAuthMode } from "./mode.js";
2
2
  import { safeNext } from "./next-path.js";
3
3
  import { SIGN_IN_PROBLEMS } from "./problems.js";
4
4
  import { type MiiUser } from "./lookup.js";
5
- import { type MiiIdentity } from "./plaza.js";
5
+ import { type MiiIdentity, type PlazaSession } from "./plaza.js";
6
6
  import { type UsersConfig } from "./users-config.js";
7
7
  export type { MiiUser } from "./lookup.js";
8
8
  export type { AuthMode } from "./mode.js";
9
- export type { MiiIdentity } from "./plaza.js";
9
+ export type { MiiIdentity, PlazaSession } from "./plaza.js";
10
10
  export type { UsersConfig } from "./users-config.js";
11
11
  export { SIGN_IN_PROBLEMS } from "./problems.js";
12
12
  export { safeNext } from "./next-path.js";
@@ -27,7 +27,7 @@ export type CreateMiiAuthOptions = {
27
27
  export type MiiAuth = {
28
28
  getUser(): Promise<MiiUser | null>;
29
29
  requireUser(): Promise<MiiUser | null>;
30
- createSession(userId: string): Promise<void>;
30
+ createSession(userId: string, plaza: PlazaSession): Promise<void>;
31
31
  destroySession(): Promise<void>;
32
32
  resolveSession(token: string): Promise<MiiUser | null>;
33
33
  deactivateUser(userId: string): Promise<void>;
@@ -45,7 +45,7 @@ export type MiiAuth = {
45
45
  token: string;
46
46
  expiresAt: Date;
47
47
  } | null>;
48
- issueSession(userId: string): Promise<{
48
+ issueSession(userId: string, plaza: PlazaSession): Promise<{
49
49
  token: string;
50
50
  expiresAt: Date;
51
51
  }>;
@@ -27,7 +27,7 @@ export function createMiiAuth(options = {}) {
27
27
  return {
28
28
  getUser: () => getUser(ctx),
29
29
  requireUser: () => requireUser(ctx),
30
- createSession: (userId) => createSession(ctx, userId),
30
+ createSession: (userId, plaza) => createSession(ctx, userId, plaza),
31
31
  destroySession: () => destroySession(ctx),
32
32
  resolveSession: (token) => resolveSession(ctx, token),
33
33
  deactivateUser: (userId) => deactivateUser(ctx, userId),
@@ -35,15 +35,15 @@ export function createMiiAuth(options = {}) {
35
35
  loginRoute: (request) => loginRoute(ctx, request),
36
36
  logoutRoute: () => logoutRoute(ctx),
37
37
  redeemPaneCode: async (code) => {
38
- const identity = await redeemWithPlaza(code);
39
- if (identity === null)
38
+ const signedIn = await redeemWithPlaza(code);
39
+ if (signedIn === null)
40
40
  return null;
41
- const user = await upsertMemberFromIdentity(ctx, identity);
41
+ const user = await upsertMemberFromIdentity(ctx, signedIn.identity);
42
42
  if (user.status !== "active")
43
43
  return null;
44
- return issueSession(ctx, user.id);
44
+ return issueSession(ctx, user.id, signedIn.session);
45
45
  },
46
- issueSession: (userId) => issueSession(ctx, userId),
46
+ issueSession: (userId, plaza) => issueSession(ctx, userId, plaza),
47
47
  upsertMemberFromIdentity: (identity) => upsertMemberFromIdentity(ctx, identity),
48
48
  SESSION_COOKIE,
49
49
  LOGIN_ACTION,
@@ -1,16 +1,13 @@
1
1
  import type { AuthContext } from "./context.js";
2
+ import { type PlazaSession } from "./plaza.js";
2
3
  /** The cookie holding this app's own session. Nothing of Plaza's is read here. */
3
4
  export declare const SESSION_COOKIE = "mii_session";
4
5
  /**
5
- * A week.
6
- *
7
- * This app holds its own sessions now, so signing in means a password typed on
8
- * its own page rather than a silent hop through a session Plaza already had —
9
- * which makes a short life felt. A week is one sign-in most people notice once.
10
- * Someone removed from the org keeps this app until their session runs out, so
11
- * deactivate them here when it has to be immediate.
6
+ * How often a session asks Plaza 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 Plaza checks membership on every chat request.
12
9
  */
13
- export declare const SESSION_TTL_MS: number;
10
+ export declare const SESSION_RECHECK_MS: number;
14
11
  export type MiiUser = {
15
12
  readonly id: string;
16
13
  readonly miiId: string | null;
@@ -19,18 +16,32 @@ export type MiiUser = {
19
16
  readonly avatarUrl: string | null;
20
17
  readonly role: string;
21
18
  };
19
+ /** A session with the Plaza token behind it, for acting on Plaza as the person. */
20
+ export type ResolvedSession = {
21
+ readonly user: MiiUser;
22
+ readonly plazaToken: string;
23
+ };
22
24
  /**
23
25
  * The user behind a session token, or null. Pure lookup — no request context —
24
26
  * so the proxy can call it with the raw cookie value. A missing, expired, or
25
- * deactivated session is null.
27
+ * deactivated session is null, and so is one Plaza no longer stands behind.
26
28
  */
27
29
  export declare function resolveSession(ctx: AuthContext, token: string): Promise<MiiUser | null>;
30
+ export declare function resolveSessionWithPlaza(ctx: AuthContext, token: string): Promise<ResolvedSession | null>;
28
31
  /**
29
- * Start a session and hand back the cookie value to set. Only the hash is
30
- * stored, so a database read cannot be replayed as a sign-in.
32
+ * Start a session and hand back the cookie value to set. It lasts exactly as
33
+ * long as the session Plaza signed. Only the cookie's hash is stored, so a
34
+ * database read cannot be replayed as a sign-in here; Plaza's token is kept as
35
+ * it is, because this server has to send it, and signing out revokes it.
31
36
  */
32
- export declare function issueSession(ctx: AuthContext, userId: string): Promise<{
37
+ export declare function issueSession(ctx: AuthContext, userId: string, plaza: PlazaSession): Promise<{
33
38
  token: string;
34
39
  expiresAt: Date;
35
40
  }>;
41
+ /**
42
+ * End one session by its cookie value, here and on Plaza. The session here ends
43
+ * whatever Plaza says: a Plaza that cannot be reached is reported, and signing
44
+ * out still signs out.
45
+ */
46
+ export declare function revokeSession(ctx: AuthContext, token: string): Promise<void>;
36
47
  export declare function hashToken(token: string): string;
@@ -1,55 +1,116 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { checkPlazaSession, revokePlazaSession } from "./plaza.js";
2
3
  /** The cookie holding this app's own session. Nothing of Plaza's is read here. */
3
4
  export const SESSION_COOKIE = "mii_session";
4
5
  /**
5
- * A week.
6
- *
7
- * This app holds its own sessions now, so signing in means a password typed on
8
- * its own page rather than a silent hop through a session Plaza already had —
9
- * which makes a short life felt. A week is one sign-in most people notice once.
10
- * Someone removed from the org keeps this app until their session runs out, so
11
- * deactivate them here when it has to be immediate.
6
+ * How often a session asks Plaza 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 Plaza checks membership on every chat request.
12
9
  */
13
- export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
10
+ export const SESSION_RECHECK_MS = 5 * 60 * 1000;
14
11
  /**
15
12
  * The user behind a session token, or null. Pure lookup — no request context —
16
13
  * so the proxy can call it with the raw cookie value. A missing, expired, or
17
- * deactivated session is null.
14
+ * deactivated session is null, and so is one Plaza no longer stands behind.
18
15
  */
19
16
  export async function resolveSession(ctx, token) {
17
+ return (await resolveSessionWithPlaza(ctx, token))?.user ?? null;
18
+ }
19
+ export async function resolveSessionWithPlaza(ctx, token) {
20
20
  const sql = await ctx.getSql();
21
+ const tokenHash = hashToken(token);
21
22
  const rows = await sql `
22
- select u.id, u.mii_id, u.name, u.email, u.${sql(ctx.users.avatarColumn)} as avatar_url, u.role
23
+ select u.id, u.mii_id, u.name, u.email, u.${sql(ctx.users.avatarColumn)} as avatar_url, u.role,
24
+ s.plaza_token,
25
+ (s.checked_at is null or s.checked_at < now() - ${`${SESSION_RECHECK_MS} milliseconds`}::interval) as due
23
26
  from mii_auth_sessions s
24
27
  join ${sql(ctx.users.table)} u on u.id = s.user_id
25
- where s.token_hash = ${hashToken(token)}
28
+ where s.token_hash = ${tokenHash}
26
29
  and s.expires_at > now()
30
+ and s.plaza_token is not null
27
31
  and u.status = 'active'
28
32
  limit 1`;
29
33
  const row = rows[0];
30
34
  if (row === undefined)
31
35
  return null;
36
+ if (row.due && !(await stillStands(ctx, tokenHash, row.plaza_token))) {
37
+ return null;
38
+ }
32
39
  return {
33
- id: row.id,
34
- miiId: row.mii_id,
35
- name: row.name,
36
- email: row.email,
37
- avatarUrl: row.avatar_url,
38
- role: row.role
40
+ user: {
41
+ id: row.id,
42
+ miiId: row.mii_id,
43
+ name: row.name,
44
+ email: row.email,
45
+ avatarUrl: row.avatar_url,
46
+ role: row.role
47
+ },
48
+ plazaToken: row.plaza_token
39
49
  };
40
50
  }
41
51
  /**
42
- * Start a session and hand back the cookie value to set. Only the hash is
43
- * stored, so a database read cannot be replayed as a sign-in.
52
+ * Ask Plaza whether a session still stands, once per recheck window however
53
+ * many requests arrive together: the first to claim the window asks, the rest
54
+ * go on the answer it last had. A session Plaza has ended is deleted. Plaza
55
+ * being unreachable is reported and the session kept — an outage there must not
56
+ * sign every app's people out.
57
+ */
58
+ async function stillStands(ctx, tokenHash, plazaToken) {
59
+ const sql = await ctx.getSql();
60
+ const claimed = await sql `
61
+ update mii_auth_sessions set checked_at = now()
62
+ where token_hash = ${tokenHash}
63
+ and (checked_at is null or checked_at < now() - ${`${SESSION_RECHECK_MS} milliseconds`}::interval)
64
+ returning token_hash`;
65
+ if (claimed.length === 0)
66
+ return true;
67
+ let answer;
68
+ try {
69
+ answer = await checkPlazaSession(plazaToken);
70
+ }
71
+ catch (error) {
72
+ ctx.reportError(error, { where: "mii-auth/session", step: "recheck" });
73
+ return true;
74
+ }
75
+ if (answer === "ended") {
76
+ await sql `delete from mii_auth_sessions where token_hash = ${tokenHash}`;
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+ /**
82
+ * Start a session and hand back the cookie value to set. It lasts exactly as
83
+ * long as the session Plaza signed. Only the cookie's hash is stored, so a
84
+ * database read cannot be replayed as a sign-in here; Plaza's token is kept as
85
+ * it is, because this server has to send it, and signing out revokes it.
44
86
  */
45
- export async function issueSession(ctx, userId) {
87
+ export async function issueSession(ctx, userId, plaza) {
46
88
  const token = randomBytes(32).toString("base64url");
47
- const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
48
89
  const sql = await ctx.getSql();
49
90
  await sql `
50
- insert into mii_auth_sessions (token_hash, user_id, expires_at)
51
- values (${hashToken(token)}, ${userId}, ${expiresAt})`;
52
- return { token, expiresAt };
91
+ insert into mii_auth_sessions (token_hash, user_id, expires_at, plaza_token, checked_at)
92
+ values (${hashToken(token)}, ${userId}, ${plaza.expiresAt}, ${plaza.token}, now())`;
93
+ return { token, expiresAt: plaza.expiresAt };
94
+ }
95
+ /**
96
+ * End one session by its cookie value, here and on Plaza. The session here ends
97
+ * whatever Plaza says: a Plaza that cannot be reached is reported, and signing
98
+ * out still signs out.
99
+ */
100
+ export async function revokeSession(ctx, token) {
101
+ const sql = await ctx.getSql();
102
+ const ended = await sql `
103
+ delete from mii_auth_sessions where token_hash = ${hashToken(token)}
104
+ returning plaza_token`;
105
+ const plazaToken = ended[0]?.plaza_token;
106
+ if (plazaToken === null || plazaToken === undefined)
107
+ return;
108
+ try {
109
+ await revokePlazaSession(plazaToken);
110
+ }
111
+ catch (error) {
112
+ ctx.reportError(error, { where: "mii-auth/session", step: "revoke" });
113
+ }
53
114
  }
54
115
  export function hashToken(token) {
55
116
  return createHash("sha256").update(token).digest("hex");
@@ -6,6 +6,16 @@ export type MiiIdentity = {
6
6
  readonly name: string;
7
7
  readonly avatarUrl: string | null;
8
8
  };
9
+ /**
10
+ * The session Plaza signs for someone it just signed in. This app keeps the
11
+ * token on its own session, ends that session when the token expires, and sends
12
+ * the token back whenever it acts for the person (their chat), so Plaza can
13
+ * check they are still in the org.
14
+ */
15
+ export type PlazaSession = {
16
+ readonly token: string;
17
+ readonly expiresAt: Date;
18
+ };
9
19
  /**
10
20
  * Why a sign-in did not happen, in words this app can show as they are.
11
21
  *
@@ -19,20 +29,19 @@ export type SignInFailure = {
19
29
  readonly message: string;
20
30
  readonly cause?: unknown;
21
31
  };
22
- export type SignInResult = {
23
- readonly ok: true;
32
+ export type SignedIn = {
24
33
  readonly identity: MiiIdentity;
25
- } | {
34
+ readonly session: PlazaSession;
35
+ };
36
+ export type SignInResult = ({
37
+ readonly ok: true;
38
+ } & SignedIn) | {
26
39
  readonly ok: false;
27
40
  readonly failure: SignInFailure;
28
41
  };
29
42
  /**
30
- * Check an email and password with Plaza, and get back who the person is.
31
- *
32
- * One round trip. It used to be two — a code, then a redeem — which doubled
33
- * the wait on a form submit for nothing: this server is the only party on the
34
- * line and is trusted to hold the answer. The code path still exists for the
35
- * App pane, where a browser carries it.
43
+ * Check an email and password with Plaza, and get back who the person is and
44
+ * the session Plaza signed for them.
36
45
  */
37
46
  export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
38
47
  /**
@@ -42,5 +51,16 @@ export declare function signInWithPlaza(email: string, password: string): Promis
42
51
  * that reaches the route's error reporting rather than looking like a stale
43
52
  * code and sending the person round again.
44
53
  */
45
- export declare function redeemWithPlaza(code: string): Promise<MiiIdentity | null>;
54
+ export declare function redeemWithPlaza(code: string): Promise<SignedIn | null>;
55
+ /**
56
+ * Whether Plaza still stands behind a session: "ended" when the token is no
57
+ * longer good or the person has left the org. Throws when Plaza cannot say, so
58
+ * an outage is reported rather than read as either answer.
59
+ */
60
+ export declare function checkPlazaSession(token: string): Promise<"valid" | "ended">;
61
+ /**
62
+ * Tell Plaza a session is over, so the token stops working now rather than when
63
+ * it expires. Throws when Plaza did not take it.
64
+ */
65
+ export declare function revokePlazaSession(token: string): Promise<void>;
46
66
  export declare function failureFor(status: number, body: string): SignInFailure;
@@ -1,11 +1,7 @@
1
1
  import { miiAuthConfig } from "./config.js";
2
2
  /**
3
- * Check an email and password with Plaza, and get back who the person is.
4
- *
5
- * One round trip. It used to be two — a code, then a redeem — which doubled
6
- * the wait on a form submit for nothing: this server is the only party on the
7
- * line and is trusted to hold the answer. The code path still exists for the
8
- * App pane, where a browser carries it.
3
+ * Check an email and password with Plaza, and get back who the person is and
4
+ * the session Plaza signed for them.
9
5
  */
10
6
  export async function signInWithPlaza(email, password) {
11
7
  const { plazaUrl, appId } = miiAuthConfig();
@@ -30,16 +26,16 @@ export async function signInWithPlaza(email, password) {
30
26
  }
31
27
  if (response.ok) {
32
28
  const body = (await response.json());
33
- const identity = asIdentity(body);
34
- if (identity !== null) {
35
- return { ok: true, identity };
29
+ const signedIn = asSignedIn(body);
30
+ if (signedIn !== null) {
31
+ return { ok: true, ...signedIn };
36
32
  }
37
33
  return {
38
34
  ok: false,
39
35
  failure: {
40
36
  kind: "unavailable",
41
37
  message: "Sign-in answered with something unexpected. Try again.",
42
- cause: new Error(`Plaza sign-in answered 200 without an identity: ${JSON.stringify(body)}`)
38
+ cause: new Error(`Plaza sign-in answered 200 without an identity and session: ${JSON.stringify(body)}`)
43
39
  }
44
40
  };
45
41
  }
@@ -67,22 +63,70 @@ export async function redeemWithPlaza(code) {
67
63
  return null;
68
64
  }
69
65
  const body = (await response.json());
70
- const identity = asIdentity(body);
71
- if (identity === null) {
72
- throw new Error(`Plaza redeem answered 200 without an identity: ${JSON.stringify(body)}`);
66
+ const signedIn = asSignedIn(body);
67
+ if (signedIn === null) {
68
+ throw new Error(`Plaza redeem answered 200 without an identity and session: ${JSON.stringify(body)}`);
73
69
  }
74
- return identity;
70
+ return signedIn;
75
71
  }
76
- function asIdentity(body) {
77
- if (typeof body.miiId !== "string" || typeof body.name !== "string") {
72
+ /**
73
+ * Whether Plaza still stands behind a session: "ended" when the token is no
74
+ * longer good or the person has left the org. Throws when Plaza cannot say, so
75
+ * an outage is reported rather than read as either answer.
76
+ */
77
+ export async function checkPlazaSession(token) {
78
+ const { plazaUrl } = miiAuthConfig();
79
+ // Asked from inside a page load, so a Plaza that hangs must not hang the page.
80
+ const response = await fetch(`${plazaUrl}/api/app-chat/v1/session`, {
81
+ headers: { Authorization: `Bearer ${token}` },
82
+ cache: "no-store",
83
+ signal: AbortSignal.timeout(PLAZA_SESSION_TIMEOUT_MS)
84
+ });
85
+ if (response.ok)
86
+ return "valid";
87
+ if (response.status === 401 || response.status === 403)
88
+ return "ended";
89
+ throw new Error(`Plaza session check answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
90
+ }
91
+ /**
92
+ * Tell Plaza a session is over, so the token stops working now rather than when
93
+ * it expires. Throws when Plaza did not take it.
94
+ */
95
+ export async function revokePlazaSession(token) {
96
+ const { plazaUrl } = miiAuthConfig();
97
+ const response = await fetch(`${plazaUrl}/api/app-chat/v1/session`, {
98
+ method: "DELETE",
99
+ headers: { Authorization: `Bearer ${token}` },
100
+ cache: "no-store",
101
+ signal: AbortSignal.timeout(PLAZA_SESSION_TIMEOUT_MS)
102
+ });
103
+ if (!response.ok) {
104
+ throw new Error(`Plaza session revoke answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
105
+ }
106
+ }
107
+ const PLAZA_SESSION_TIMEOUT_MS = 5000;
108
+ function asSignedIn(body) {
109
+ const session = body.session;
110
+ if (typeof body.miiId !== "string" ||
111
+ typeof body.orgId !== "string" ||
112
+ typeof body.name !== "string" ||
113
+ typeof session?.token !== "string" ||
114
+ typeof session.expiresAt !== "string") {
115
+ return null;
116
+ }
117
+ const expiresAt = new Date(session.expiresAt);
118
+ if (Number.isNaN(expiresAt.getTime())) {
78
119
  return null;
79
120
  }
80
121
  return {
81
- miiId: body.miiId,
82
- orgId: typeof body.orgId === "string" ? body.orgId : "",
83
- email: typeof body.email === "string" ? body.email : null,
84
- name: body.name,
85
- avatarUrl: typeof body.avatarUrl === "string" ? body.avatarUrl : null
122
+ identity: {
123
+ miiId: body.miiId,
124
+ orgId: body.orgId,
125
+ email: typeof body.email === "string" ? body.email : null,
126
+ name: body.name,
127
+ avatarUrl: typeof body.avatarUrl === "string" ? body.avatarUrl : null
128
+ },
129
+ session: { token: session.token, expiresAt }
86
130
  };
87
131
  }
88
132
  export function failureFor(status, body) {
@@ -73,7 +73,7 @@ export async function loginRoute(ctx, request) {
73
73
  // Plaza has no idea; this is the app's own switch.
74
74
  return answer("deactivated", 403);
75
75
  }
76
- await createSession(ctx, user.id);
76
+ await createSession(ctx, user.id, attempt.session);
77
77
  return answer(null, 200);
78
78
  }
79
79
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import type { AuthContext } from "./context.js";
2
2
  import { type MiiUser } from "./lookup.js";
3
+ import type { PlazaSession } from "./plaza.js";
3
4
  /**
4
5
  * The signed-in user, or null. Use in API routes and anywhere "who is this, if
5
6
  * anyone" is the question. For pages that need a person, prefer requireUser().
@@ -13,8 +14,8 @@ export declare function getUser(ctx: AuthContext): Promise<MiiUser | null>;
13
14
  * app is public it returns null and your pages render for an anonymous visitor.
14
15
  */
15
16
  export declare function requireUser(ctx: AuthContext): Promise<MiiUser | null>;
16
- /** Start a session for a user and set its cookie. */
17
- export declare function createSession(ctx: AuthContext, userId: string): Promise<void>;
17
+ /** Start a session for a user and the session Plaza signed, and set its cookie. */
18
+ export declare function createSession(ctx: AuthContext, userId: string, plaza: PlazaSession): Promise<void>;
18
19
  /** End the current session, if any, and clear its cookie. */
19
20
  export declare function destroySession(ctx: AuthContext): Promise<void>;
20
21
  /**
@@ -1,6 +1,6 @@
1
1
  import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
- import { hashToken, issueSession, resolveSession, SESSION_COOKIE } from "./lookup.js";
3
+ import { issueSession, resolveSession, revokeSession, SESSION_COOKIE } from "./lookup.js";
4
4
  import { getAuthMode } from "./mode.js";
5
5
  import { safeNext } from "./next-path.js";
6
6
  /**
@@ -29,9 +29,9 @@ export async function requireUser(ctx) {
29
29
  const path = (await headers()).get("x-mii-pathname") ?? "/";
30
30
  redirect(`/login?next=${encodeURIComponent(safeNext(path))}`);
31
31
  }
32
- /** Start a session for a user and set its cookie. */
33
- export async function createSession(ctx, userId) {
34
- const { token, expiresAt } = await issueSession(ctx, userId);
32
+ /** Start a session for a user and the session Plaza signed, and set its cookie. */
33
+ export async function createSession(ctx, userId, plaza) {
34
+ const { token, expiresAt } = await issueSession(ctx, userId, plaza);
35
35
  (await cookies()).set(SESSION_COOKIE, token, {
36
36
  ...(await cookieOptions()),
37
37
  expires: expiresAt
@@ -42,8 +42,7 @@ export async function destroySession(ctx) {
42
42
  const store = await cookies();
43
43
  const token = store.get(SESSION_COOKIE)?.value;
44
44
  if (token !== undefined) {
45
- const sql = await ctx.getSql();
46
- await sql `delete from mii_auth_sessions where token_hash = ${hashToken(token)}`;
45
+ await revokeSession(ctx, token);
47
46
  store.set(SESSION_COOKIE, "", { ...(await cookieOptions()), maxAge: 0 });
48
47
  }
49
48
  }
@@ -0,0 +1,8 @@
1
+ import type { Brand } from "./widget/app.js";
2
+ export { CHAT_BASE_PATH } from "./paths.js";
3
+ export declare function MiiChat({ enabled }: {
4
+ readonly enabled: boolean;
5
+ }): null;
6
+ export declare function readBrand(): Brand;
7
+ /** `217 84% 52%` is shadcn's bare HSL; anything else is already a colour. */
8
+ export declare function asColour(value: string): string | null;