@shubh90/app-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -0
- package/dist/auth/config.d.ts +5 -0
- package/dist/auth/config.js +12 -0
- package/dist/auth/context.d.ts +14 -0
- package/dist/auth/context.js +1 -0
- package/dist/auth/db.d.ts +9 -0
- package/dist/auth/db.js +62 -0
- package/dist/auth/index.d.ts +38 -0
- package/dist/auth/index.js +36 -0
- package/dist/auth/lookup.d.ts +36 -0
- package/dist/auth/lookup.js +56 -0
- package/dist/auth/mode.d.ts +2 -0
- package/dist/auth/mode.js +40 -0
- package/dist/auth/next-path.d.ts +1 -0
- package/dist/auth/next-path.js +10 -0
- package/dist/auth/plaza.d.ts +46 -0
- package/dist/auth/plaza.js +111 -0
- package/dist/auth/problems.d.ts +4 -0
- package/dist/auth/problems.js +36 -0
- package/dist/auth/routes.d.ts +19 -0
- package/dist/auth/routes.js +108 -0
- package/dist/auth/session.d.ts +35 -0
- package/dist/auth/session.js +68 -0
- package/dist/auth/users-config.d.ts +29 -0
- package/dist/auth/users-config.js +22 -0
- package/dist/auth/users.d.ts +8 -0
- package/dist/auth/users.js +58 -0
- package/dist/next/index.d.ts +17 -0
- package/dist/next/index.js +70 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @shubh90/app-runtime
|
|
2
|
+
|
|
3
|
+
The platform contract every mii org app depends on — **sign-in** and the
|
|
4
|
+
**Next.js config an app must not diverge from** — as a versioned package instead
|
|
5
|
+
of files copied into each app repo on every agent run.
|
|
6
|
+
|
|
7
|
+
This is Phase 0 + Phase 1 of `docs/handoffs/2026-08-24-platform-runtime-plan.md`:
|
|
8
|
+
replace the kit-sync "vendoring" model (force-overwrite platform files in each
|
|
9
|
+
repo, which caused the fleet-wide `TS2307`, the stuck rebases, and the framing
|
|
10
|
+
white-screens) with a dependency an app installs and pins.
|
|
11
|
+
|
|
12
|
+
## What's in it
|
|
13
|
+
|
|
14
|
+
- `@shubh90/app-runtime/auth` — `createMiiAuth()`, the whole sign-in flow (Plaza
|
|
15
|
+
client, session, the login/logout route handlers, `SIGN_IN_PROBLEMS`,
|
|
16
|
+
`LOGIN_ACTION`, `safeNext`). Self-contained: it depends only on `postgres`
|
|
17
|
+
and `next`, never on the app's own code. The app still renders its **own**
|
|
18
|
+
login page look.
|
|
19
|
+
- `@shubh90/app-runtime/next` — `withMiiPlatform(nextConfig)`, which force-merges the
|
|
20
|
+
framing headers Plaza's App pane needs so an app cannot forbid its own
|
|
21
|
+
embedding (the Newmark white-screen bug becomes impossible).
|
|
22
|
+
|
|
23
|
+
## Why a package (not the kit sync)
|
|
24
|
+
|
|
25
|
+
A published package physically cannot `import "@/components/..."`, so the
|
|
26
|
+
class of bug where a synced login page imported an app UI component it lacked is
|
|
27
|
+
gone. Nothing is written into the app's tree each run, so the tree stays clean
|
|
28
|
+
and autosave never fights a kit-dirtied tree. Apps pin a version; a breaking
|
|
29
|
+
Plaza contract change is a semver major with a deprecation window, not a flag
|
|
30
|
+
day.
|
|
31
|
+
|
|
32
|
+
## Publishing (GitHub Packages)
|
|
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`.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# maintainers, from packages/app-runtime:
|
|
40
|
+
npm version patch # or minor / major
|
|
41
|
+
git tag app-runtime-v$(node -p "require('./package.json').version")
|
|
42
|
+
git push --tags # CI builds and publishes
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Installing in an org app
|
|
46
|
+
|
|
47
|
+
Add an `.npmrc` to the app repo so `@mii` resolves to GitHub Packages,
|
|
48
|
+
authenticated by the token the app's build already has:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
@shubh9:registry=https://npm.pkg.github.com
|
|
52
|
+
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install @shubh90/app-runtime
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Wiring it up (what each app file becomes)
|
|
62
|
+
|
|
63
|
+
**One binding** (`src/lib/mii-auth.ts`) — this is where the old `tables.ts`
|
|
64
|
+
config goes now:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createMiiAuth } from "@shubh90/app-runtime/auth";
|
|
68
|
+
import * as Sentry from "@sentry/nextjs"; // optional
|
|
69
|
+
|
|
70
|
+
export const auth = createMiiAuth({
|
|
71
|
+
// Only if the app kept a pre-kit user table (CBRE, Newmark, WBP):
|
|
72
|
+
// users: { table: "user", idFrom: "kit", emailRequired: true, adoptByEmail: true, ensureSchema: false, insertDefaults: { emailVerified: false } },
|
|
73
|
+
reportError: (error, tags) => Sentry.captureException(error, { tags })
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**The route handlers** become one line each:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/app/api/mii-auth/login/route.ts
|
|
81
|
+
import { auth } from "@/lib/mii-auth";
|
|
82
|
+
export const dynamic = "force-dynamic";
|
|
83
|
+
export const POST = (request: Request) => auth.loginRoute(request);
|
|
84
|
+
|
|
85
|
+
// src/app/api/mii-auth/logout/route.ts
|
|
86
|
+
import { auth } from "@/lib/mii-auth";
|
|
87
|
+
export const dynamic = "force-dynamic";
|
|
88
|
+
export const POST = () => auth.logoutRoute();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Guarding pages** (`getUser` / `requireUser` as before):
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { auth } from "@/lib/mii-auth";
|
|
95
|
+
const user = await auth.requireUser(); // in the authenticated layout
|
|
96
|
+
const user = await auth.getUser(); // in API routes: MiiUser | null
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**The login page** stays the app's own look. It reads the pieces from the
|
|
100
|
+
binding rather than a copied `problems.ts` / route:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { auth } from "@/lib/mii-auth";
|
|
104
|
+
const problem = params.error ? auth.SIGN_IN_PROBLEMS[params.error] : null;
|
|
105
|
+
// <form method="post" action={auth.LOGIN_ACTION}> ... </form>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**next.config** wraps the app's own config:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { withMiiPlatform } from "@shubh90/app-runtime/next";
|
|
112
|
+
export default withMiiPlatform({ /* the app's own next config */ });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Migration order (rollout)
|
|
116
|
+
|
|
117
|
+
See the plan doc. In short: publish `@shubh90/app-runtime`, convert one app on a
|
|
118
|
+
test org and prove sign-in in the pane, then a version-bump bot adopts it across
|
|
119
|
+
the fleet, then delete `syncPlatformKit` / `PLATFORM_OWNED_PREFIXES` and the
|
|
120
|
+
`backfill-org-app-*` scripts.
|
|
121
|
+
|
|
122
|
+
## Parity with the kit it replaces
|
|
123
|
+
|
|
124
|
+
The auth logic is ported verbatim from
|
|
125
|
+
`infra/provisioning/org-app-template/src/lib/mii-auth`; only the module-level
|
|
126
|
+
`USERS`/`authSql` singletons became a `createMiiAuth` argument, and the login
|
|
127
|
+
route's hard `@sentry/nextjs` import became the injected `reportError`. The
|
|
128
|
+
existing `next-path` and `plaza` unit tests are ported (`test/`), plus new
|
|
129
|
+
coverage for `withMiiPlatform` and the users config.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Which app this is and which Plaza 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
|
+
export function miiAuthConfig() {
|
|
6
|
+
const plazaUrl = process.env.MII_PLAZA_URL?.trim().replace(/\/+$/, "");
|
|
7
|
+
const appId = process.env.MII_APP_ID?.trim();
|
|
8
|
+
if (!plazaUrl || !appId) {
|
|
9
|
+
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.");
|
|
10
|
+
}
|
|
11
|
+
return { plazaUrl, appId };
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthSql } from "./db.js";
|
|
2
|
+
import type { ResolvedUsers } from "./users-config.js";
|
|
3
|
+
/**
|
|
4
|
+
* The per-app binding the db-touching helpers close over: the connection
|
|
5
|
+
* factory and the resolved user-table config. Built once by `createMiiAuth`
|
|
6
|
+
* and threaded in, so nothing here reads a module-level singleton — the reason
|
|
7
|
+
* this became a package instead of files copied into every repo.
|
|
8
|
+
*/
|
|
9
|
+
export type AuthContext = {
|
|
10
|
+
readonly getSql: () => Promise<AuthSql>;
|
|
11
|
+
readonly users: ResolvedUsers;
|
|
12
|
+
/** Where the app wants failures that are ours reported (Sentry, etc.). */
|
|
13
|
+
readonly reportError: (error: unknown, context: Record<string, string>) => void;
|
|
14
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import type { ResolvedUsers } from "./users-config.js";
|
|
3
|
+
export type AuthSql = ReturnType<typeof postgres>;
|
|
4
|
+
/**
|
|
5
|
+
* A connection factory bound to one app's user config. Memoizes a single pool
|
|
6
|
+
* per process; a failed init (database briefly unreachable) does not wedge
|
|
7
|
+
* sign-in for the life of the process — the next request tries again.
|
|
8
|
+
*/
|
|
9
|
+
export declare function makeAuthSql(users: ResolvedUsers): () => Promise<AuthSql>;
|
package/dist/auth/db.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The kit's own database access, on the one dependency every org app has: the
|
|
2
|
+
// `postgres` driver. No Drizzle, no imports from the rest of the app — apps
|
|
3
|
+
// cut from older templates (no src/db/schema.ts, no drizzle-orm installed)
|
|
4
|
+
// still sign in, which is the point.
|
|
5
|
+
//
|
|
6
|
+
// The tables are created on first use with the same statements
|
|
7
|
+
// migrations/0004_mii_auth.sql ships, so an app whose database predates that
|
|
8
|
+
// migration heals itself the first time someone signs in — unless the app
|
|
9
|
+
// brought its own user table (UsersConfig), which has its own migrations.
|
|
10
|
+
import postgres from "postgres";
|
|
11
|
+
// Verbatim from migrations/0004_mii_auth.sql (keep the two in step):
|
|
12
|
+
// idempotent, so a database that already has the tables is untouched.
|
|
13
|
+
const DDL = [
|
|
14
|
+
`create table if not exists mii_auth_users (
|
|
15
|
+
id uuid primary key default gen_random_uuid(),
|
|
16
|
+
mii_id uuid unique,
|
|
17
|
+
email text,
|
|
18
|
+
name text not null,
|
|
19
|
+
avatar_url text,
|
|
20
|
+
kind text not null default 'member',
|
|
21
|
+
role text not null default 'member',
|
|
22
|
+
status text not null default 'active',
|
|
23
|
+
created_at timestamptz not null default now(),
|
|
24
|
+
last_login_at timestamptz
|
|
25
|
+
)`,
|
|
26
|
+
`create index if not exists mii_auth_users_email_idx on mii_auth_users (email)`,
|
|
27
|
+
`create table if not exists mii_auth_sessions (
|
|
28
|
+
token_hash text primary key,
|
|
29
|
+
user_id uuid not null references mii_auth_users(id) on delete cascade,
|
|
30
|
+
expires_at timestamptz not null,
|
|
31
|
+
created_at timestamptz not null default now()
|
|
32
|
+
)`,
|
|
33
|
+
`create index if not exists mii_auth_sessions_user_idx on mii_auth_sessions (user_id)`
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* A connection factory bound to one app's user config. Memoizes a single pool
|
|
37
|
+
* per process; a failed init (database briefly unreachable) does not wedge
|
|
38
|
+
* sign-in for the life of the process — the next request tries again.
|
|
39
|
+
*/
|
|
40
|
+
export function makeAuthSql(users) {
|
|
41
|
+
let ready = null;
|
|
42
|
+
return () => {
|
|
43
|
+
if (ready === null) {
|
|
44
|
+
ready = (async () => {
|
|
45
|
+
const url = process.env.DATABASE_URL;
|
|
46
|
+
if (!url)
|
|
47
|
+
throw new Error("DATABASE_URL is required for sign-in.");
|
|
48
|
+
const client = postgres(url, { prepare: false, max: 2 });
|
|
49
|
+
if (users.ensureSchema) {
|
|
50
|
+
for (const statement of DDL) {
|
|
51
|
+
await client.unsafe(statement);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return client;
|
|
55
|
+
})();
|
|
56
|
+
ready.catch(() => {
|
|
57
|
+
ready = null;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return ready;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 } from "./lookup.js";
|
|
5
|
+
import { type UsersConfig } from "./users-config.js";
|
|
6
|
+
export type { MiiUser } from "./lookup.js";
|
|
7
|
+
export type { AuthMode } from "./mode.js";
|
|
8
|
+
export type { MiiIdentity } from "./plaza.js";
|
|
9
|
+
export type { UsersConfig } from "./users-config.js";
|
|
10
|
+
export { SIGN_IN_PROBLEMS } from "./problems.js";
|
|
11
|
+
export { safeNext } from "./next-path.js";
|
|
12
|
+
export { SESSION_COOKIE } from "./lookup.js";
|
|
13
|
+
export type CreateMiiAuthOptions = {
|
|
14
|
+
/** Which table holds this app's people, if not the default `mii_auth_users`. */
|
|
15
|
+
readonly users?: UsersConfig;
|
|
16
|
+
/**
|
|
17
|
+
* Where to report failures that are ours, not the person's (Plaza down, a
|
|
18
|
+
* refused write). Wire it to Sentry: `reportError: (e, tags) =>
|
|
19
|
+
* Sentry.captureException(e, { tags })`. Defaults to console.error.
|
|
20
|
+
*/
|
|
21
|
+
readonly reportError?: (error: unknown, context: Record<string, string>) => void;
|
|
22
|
+
};
|
|
23
|
+
export type MiiAuth = {
|
|
24
|
+
getUser(): Promise<MiiUser | null>;
|
|
25
|
+
requireUser(): Promise<MiiUser | null>;
|
|
26
|
+
createSession(userId: string): Promise<void>;
|
|
27
|
+
destroySession(): Promise<void>;
|
|
28
|
+
resolveSession(token: string): Promise<MiiUser | null>;
|
|
29
|
+
deactivateUser(userId: string): Promise<void>;
|
|
30
|
+
getAuthMode: typeof getAuthMode;
|
|
31
|
+
loginRoute(request: Request): Promise<Response>;
|
|
32
|
+
logoutRoute(): Promise<Response>;
|
|
33
|
+
readonly SESSION_COOKIE: string;
|
|
34
|
+
readonly LOGIN_ACTION: string;
|
|
35
|
+
readonly SIGN_IN_PROBLEMS: typeof SIGN_IN_PROBLEMS;
|
|
36
|
+
readonly safeNext: typeof safeNext;
|
|
37
|
+
};
|
|
38
|
+
export declare function createMiiAuth(options?: CreateMiiAuthOptions): MiiAuth;
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { loginRoute, logoutRoute, LOGIN_ACTION } from "./routes.js";
|
|
6
|
+
import { createSession, destroySession, getUser, requireUser } from "./session.js";
|
|
7
|
+
import { resolveSession, SESSION_COOKIE } from "./lookup.js";
|
|
8
|
+
import { deactivateUser } from "./users.js";
|
|
9
|
+
import { resolveUsers } from "./users-config.js";
|
|
10
|
+
export { SIGN_IN_PROBLEMS } from "./problems.js";
|
|
11
|
+
export { safeNext } from "./next-path.js";
|
|
12
|
+
export { SESSION_COOKIE } from "./lookup.js";
|
|
13
|
+
export function createMiiAuth(options = {}) {
|
|
14
|
+
const users = resolveUsers(options.users);
|
|
15
|
+
const ctx = {
|
|
16
|
+
getSql: makeAuthSql(users),
|
|
17
|
+
users,
|
|
18
|
+
reportError: options.reportError ??
|
|
19
|
+
((error, context) => console.error("mii-auth error", context, error))
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
getUser: () => getUser(ctx),
|
|
23
|
+
requireUser: () => requireUser(ctx),
|
|
24
|
+
createSession: (userId) => createSession(ctx, userId),
|
|
25
|
+
destroySession: () => destroySession(ctx),
|
|
26
|
+
resolveSession: (token) => resolveSession(ctx, token),
|
|
27
|
+
deactivateUser: (userId) => deactivateUser(ctx, userId),
|
|
28
|
+
getAuthMode,
|
|
29
|
+
loginRoute: (request) => loginRoute(ctx, request),
|
|
30
|
+
logoutRoute: () => logoutRoute(ctx),
|
|
31
|
+
SESSION_COOKIE,
|
|
32
|
+
LOGIN_ACTION,
|
|
33
|
+
SIGN_IN_PROBLEMS,
|
|
34
|
+
safeNext
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AuthContext } from "./context.js";
|
|
2
|
+
/** The cookie holding this app's own session. Nothing of Plaza's is read here. */
|
|
3
|
+
export declare const SESSION_COOKIE = "mii_session";
|
|
4
|
+
/**
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SESSION_TTL_MS: number;
|
|
14
|
+
export type MiiUser = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly miiId: string | null;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly email: string | null;
|
|
19
|
+
readonly avatarUrl: string | null;
|
|
20
|
+
readonly role: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The user behind a session token, or null. Pure lookup — no request context —
|
|
24
|
+
* so the proxy can call it with the raw cookie value. A missing, expired, or
|
|
25
|
+
* deactivated session is null.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveSession(ctx: AuthContext, token: string): Promise<MiiUser | null>;
|
|
28
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
export declare function issueSession(ctx: AuthContext, userId: string): Promise<{
|
|
33
|
+
token: string;
|
|
34
|
+
expiresAt: Date;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function hashToken(token: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
/** The cookie holding this app's own session. Nothing of Plaza's is read here. */
|
|
3
|
+
export const SESSION_COOKIE = "mii_session";
|
|
4
|
+
/**
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* The user behind a session token, or null. Pure lookup — no request context —
|
|
16
|
+
* so the proxy can call it with the raw cookie value. A missing, expired, or
|
|
17
|
+
* deactivated session is null.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveSession(ctx, token) {
|
|
20
|
+
const sql = await ctx.getSql();
|
|
21
|
+
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
|
+
from mii_auth_sessions s
|
|
24
|
+
join ${sql(ctx.users.table)} u on u.id = s.user_id
|
|
25
|
+
where s.token_hash = ${hashToken(token)}
|
|
26
|
+
and s.expires_at > now()
|
|
27
|
+
and u.status = 'active'
|
|
28
|
+
limit 1`;
|
|
29
|
+
const row = rows[0];
|
|
30
|
+
if (row === undefined)
|
|
31
|
+
return null;
|
|
32
|
+
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
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
export async function issueSession(ctx, userId) {
|
|
46
|
+
const token = randomBytes(32).toString("base64url");
|
|
47
|
+
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
48
|
+
const sql = await ctx.getSql();
|
|
49
|
+
await sql `
|
|
50
|
+
insert into mii_auth_sessions (token_hash, user_id, expires_at)
|
|
51
|
+
values (${hashToken(token)}, ${userId}, ${expiresAt})`;
|
|
52
|
+
return { token, expiresAt };
|
|
53
|
+
}
|
|
54
|
+
export function hashToken(token) {
|
|
55
|
+
return createHash("sha256").update(token).digest("hex");
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { miiAuthConfig } from "./config.js";
|
|
2
|
+
const TTL_MS = 30_000;
|
|
3
|
+
let cached = null;
|
|
4
|
+
let inflight = null;
|
|
5
|
+
export async function getAuthMode() {
|
|
6
|
+
if (cached === null || Date.now() - cached.fetchedAt >= TTL_MS) {
|
|
7
|
+
inflight ??= fetchMode()
|
|
8
|
+
.then((mode) => {
|
|
9
|
+
cached = { mode, fetchedAt: Date.now() };
|
|
10
|
+
})
|
|
11
|
+
.catch((error) => {
|
|
12
|
+
// Keep serving the last known answer through a blip. Only a cold start
|
|
13
|
+
// with Plaza unreachable has nothing to fall back to, and there the door
|
|
14
|
+
// stays shut rather than swinging open.
|
|
15
|
+
if (cached === null)
|
|
16
|
+
throw error;
|
|
17
|
+
cached = { ...cached, fetchedAt: Date.now() };
|
|
18
|
+
})
|
|
19
|
+
.finally(() => {
|
|
20
|
+
inflight = null;
|
|
21
|
+
});
|
|
22
|
+
await inflight;
|
|
23
|
+
}
|
|
24
|
+
if (cached === null) {
|
|
25
|
+
throw new Error("Could not reach Plaza to check who may open this app.");
|
|
26
|
+
}
|
|
27
|
+
return cached.mode;
|
|
28
|
+
}
|
|
29
|
+
async function fetchMode() {
|
|
30
|
+
const { plazaUrl, appId } = miiAuthConfig();
|
|
31
|
+
const response = await fetch(`${plazaUrl}/api/app-auth/config?app_id=${encodeURIComponent(appId)}`, { cache: "no-store" });
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(`Plaza app-auth config answered ${response.status}.`);
|
|
34
|
+
}
|
|
35
|
+
const body = (await response.json());
|
|
36
|
+
if (body.authMode !== "public" && body.authMode !== "members") {
|
|
37
|
+
throw new Error("Plaza app-auth config answered with an unexpected shape.");
|
|
38
|
+
}
|
|
39
|
+
return body.authMode;
|
|
40
|
+
}
|
|
@@ -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,46 @@
|
|
|
1
|
+
/** Who someone is, once Plaza 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
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Why a sign-in did not happen, in words this app can show as they are.
|
|
11
|
+
*
|
|
12
|
+
* `cause` rides along on the failures that are ours rather than the person's
|
|
13
|
+
* (Plaza unreachable, Plaza answering nonsense), so the route can report them:
|
|
14
|
+
* a sign-in that fails because of us and tells nobody is the failure mode this
|
|
15
|
+
* kit replaced.
|
|
16
|
+
*/
|
|
17
|
+
export type SignInFailure = {
|
|
18
|
+
readonly kind: "credentials" | "not_a_member" | "throttled" | "unavailable";
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly cause?: unknown;
|
|
21
|
+
};
|
|
22
|
+
export type SignInResult = {
|
|
23
|
+
readonly ok: true;
|
|
24
|
+
readonly identity: MiiIdentity;
|
|
25
|
+
} | {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly failure: SignInFailure;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
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.
|
|
36
|
+
*/
|
|
37
|
+
export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Spend a code for the person behind it, or null if it is spent or stale.
|
|
40
|
+
*
|
|
41
|
+
* Throws when Plaza itself is the problem — a 5xx or an unreadable answer — so
|
|
42
|
+
* that reaches the route's error reporting rather than looking like a stale
|
|
43
|
+
* code and sending the person round again.
|
|
44
|
+
*/
|
|
45
|
+
export declare function redeemWithPlaza(code: string): Promise<MiiIdentity | null>;
|
|
46
|
+
export declare function failureFor(status: number, body: string): SignInFailure;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { miiAuthConfig } from "./config.js";
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export async function signInWithPlaza(email, password) {
|
|
11
|
+
const { plazaUrl, appId } = miiAuthConfig();
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetch(`${plazaUrl}/api/app-auth/sign-in`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ appId, email, password }),
|
|
18
|
+
cache: "no-store"
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (cause) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
failure: {
|
|
25
|
+
kind: "unavailable",
|
|
26
|
+
message: "Sign-in is unreachable right now. Try again in a moment.",
|
|
27
|
+
cause
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
const body = (await response.json());
|
|
33
|
+
const identity = asIdentity(body);
|
|
34
|
+
if (identity !== null) {
|
|
35
|
+
return { ok: true, identity };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
failure: {
|
|
40
|
+
kind: "unavailable",
|
|
41
|
+
message: "Sign-in answered with something unexpected. Try again.",
|
|
42
|
+
cause: new Error(`Plaza sign-in answered 200 without an identity: ${JSON.stringify(body)}`)
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { ok: false, failure: failureFor(response.status, await response.text()) };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Spend a code for the person behind it, or null if it is spent or stale.
|
|
50
|
+
*
|
|
51
|
+
* Throws when Plaza itself is the problem — a 5xx or an unreadable answer — so
|
|
52
|
+
* that reaches the route's error reporting rather than looking like a stale
|
|
53
|
+
* code and sending the person round again.
|
|
54
|
+
*/
|
|
55
|
+
export async function redeemWithPlaza(code) {
|
|
56
|
+
const { plazaUrl, appId } = miiAuthConfig();
|
|
57
|
+
const response = await fetch(`${plazaUrl}/api/app-auth/redeem`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify({ appId, code }),
|
|
61
|
+
cache: "no-store"
|
|
62
|
+
});
|
|
63
|
+
if (response.status >= 500) {
|
|
64
|
+
throw new Error(`Plaza redeem answered ${response.status}: ${await response.text()}`);
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
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)}`);
|
|
73
|
+
}
|
|
74
|
+
return identity;
|
|
75
|
+
}
|
|
76
|
+
function asIdentity(body) {
|
|
77
|
+
if (typeof body.miiId !== "string" || typeof body.name !== "string") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
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
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function failureFor(status, body) {
|
|
89
|
+
if (status === 401) {
|
|
90
|
+
return { kind: "credentials", message: "Wrong email or password." };
|
|
91
|
+
}
|
|
92
|
+
if (status === 403) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "not_a_member",
|
|
95
|
+
// Said plainly: they have just proved they own the account, so this is
|
|
96
|
+
// not a secret from them, and it is the one thing they can act on.
|
|
97
|
+
message: "That account isn't set up for this app. Ask whoever runs it."
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (status === 429) {
|
|
101
|
+
return {
|
|
102
|
+
kind: "throttled",
|
|
103
|
+
message: "Too many wrong tries. Wait a minute and try again."
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
kind: "unavailable",
|
|
108
|
+
message: "Sign-in isn't answering right now. Try again in a moment.",
|
|
109
|
+
cause: new Error(`Plaza sign-in answered ${status}: ${body.slice(0, 300)}`)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Why a sign-in did not happen, in words worth reading.
|
|
2
|
+
//
|
|
3
|
+
// The code travels in the URL and the words live here, so a failure never puts
|
|
4
|
+
// an email, a password, or a message Plaza wrote into someone's address bar or
|
|
5
|
+
// their history. Each one says what to do next, because "try again" is only the
|
|
6
|
+
// right advice for some of them.
|
|
7
|
+
export const SIGN_IN_PROBLEMS = {
|
|
8
|
+
credentials: {
|
|
9
|
+
title: "Wrong email or password",
|
|
10
|
+
body: "Check both and try again. It's the same account you use for mii."
|
|
11
|
+
},
|
|
12
|
+
not_a_member: {
|
|
13
|
+
title: "That account can't open this app",
|
|
14
|
+
body: "The password was right, but the account isn't in this app's org. Ask whoever runs it to add you."
|
|
15
|
+
},
|
|
16
|
+
deactivated: {
|
|
17
|
+
title: "Your access here has been turned off",
|
|
18
|
+
body: "Ask whoever runs this app to turn it back on."
|
|
19
|
+
},
|
|
20
|
+
throttled: {
|
|
21
|
+
title: "Too many wrong tries",
|
|
22
|
+
body: "Wait a minute, then try again."
|
|
23
|
+
},
|
|
24
|
+
expired: {
|
|
25
|
+
title: "That took too long",
|
|
26
|
+
body: "The sign-in expired before it finished. Try again."
|
|
27
|
+
},
|
|
28
|
+
unavailable: {
|
|
29
|
+
title: "Sign-in isn't answering",
|
|
30
|
+
body: "This is our side, not yours. Try again in a moment."
|
|
31
|
+
},
|
|
32
|
+
missing: {
|
|
33
|
+
title: "Enter your email and password",
|
|
34
|
+
body: "Both are needed to sign in."
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AuthContext } from "./context.js";
|
|
2
|
+
/** What the login form posts to. Exported so an app's page can build its form. */
|
|
3
|
+
export declare const LOGIN_ACTION = "/api/mii-auth/login";
|
|
4
|
+
/**
|
|
5
|
+
* The login route handler. The password goes to Plaza, which owns it, and comes
|
|
6
|
+
* back as a code this server spends for who the person is. Nothing is kept here
|
|
7
|
+
* but the session that results — and that session is this app's alone.
|
|
8
|
+
*
|
|
9
|
+
* Answers a form post with a redirect and a JSON post with JSON, so the page
|
|
10
|
+
* works with no JavaScript and a script can still sign in. Every failure that
|
|
11
|
+
* is ours — Plaza down, a database write refused, anything thrown — is handed
|
|
12
|
+
* to `ctx.reportError` before the person sees "our side, try again".
|
|
13
|
+
*/
|
|
14
|
+
export declare function loginRoute(ctx: AuthContext, request: Request): Promise<Response>;
|
|
15
|
+
/**
|
|
16
|
+
* The logout route handler. Signs out of this app, and only this app. POST
|
|
17
|
+
* only: a GET that signs you out is a link someone else can make you click.
|
|
18
|
+
*/
|
|
19
|
+
export declare function logoutRoute(ctx: AuthContext): Promise<Response>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { safeNext } from "./next-path.js";
|
|
3
|
+
import { signInWithPlaza } from "./plaza.js";
|
|
4
|
+
import { createSession, destroySession } from "./session.js";
|
|
5
|
+
import { upsertMemberFromIdentity } from "./users.js";
|
|
6
|
+
/** What the login form posts to. Exported so an app's page can build its form. */
|
|
7
|
+
export const LOGIN_ACTION = "/api/mii-auth/login";
|
|
8
|
+
// What a caller that reads status codes should see. Only the form path renders
|
|
9
|
+
// words; a script needs "wrong password" and "slow down" to be different
|
|
10
|
+
// answers, which they are not if everything that is not our fault is a 401.
|
|
11
|
+
const STATUS_FOR = {
|
|
12
|
+
credentials: 401,
|
|
13
|
+
not_a_member: 403,
|
|
14
|
+
throttled: 429,
|
|
15
|
+
unavailable: 503
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The login route handler. The password goes to Plaza, which owns it, and comes
|
|
19
|
+
* back as a code this server spends for who the person is. Nothing is kept here
|
|
20
|
+
* but the session that results — and that session is this app's alone.
|
|
21
|
+
*
|
|
22
|
+
* Answers a form post with a redirect and a JSON post with JSON, so the page
|
|
23
|
+
* works with no JavaScript and a script can still sign in. Every failure that
|
|
24
|
+
* is ours — Plaza down, a database write refused, anything thrown — is handed
|
|
25
|
+
* to `ctx.reportError` before the person sees "our side, try again".
|
|
26
|
+
*/
|
|
27
|
+
export async function loginRoute(ctx, request) {
|
|
28
|
+
// A JSON post answers with JSON (a script signing in); a form post answers
|
|
29
|
+
// with a redirect (the page working with no JavaScript).
|
|
30
|
+
const isForm = !request.headers
|
|
31
|
+
.get("content-type")
|
|
32
|
+
?.includes("application/json");
|
|
33
|
+
let body;
|
|
34
|
+
if (isForm) {
|
|
35
|
+
// forEach is on FormData in both the DOM and Node typings; iterating it
|
|
36
|
+
// directly is not, and this package builds against Node's lib.
|
|
37
|
+
const fields = {};
|
|
38
|
+
(await request.formData()).forEach((value, key) => {
|
|
39
|
+
fields[key] = value;
|
|
40
|
+
});
|
|
41
|
+
body = fields;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
body = (await request.json().catch(() => null));
|
|
45
|
+
}
|
|
46
|
+
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
|
47
|
+
const password = typeof body?.password === "string" ? body.password : "";
|
|
48
|
+
const next = safeNext(typeof body?.next === "string" ? body.next : null);
|
|
49
|
+
const answer = (problem, status) => problem === null
|
|
50
|
+
? isForm
|
|
51
|
+
? seeOther(next)
|
|
52
|
+
: NextResponse.json({ ok: true })
|
|
53
|
+
: isForm
|
|
54
|
+
? seeOther(loginPath(next, problem))
|
|
55
|
+
: NextResponse.json({ error: problem }, { status });
|
|
56
|
+
if (email === "" || password === "") {
|
|
57
|
+
return answer("missing", 400);
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const attempt = await signInWithPlaza(email, password);
|
|
61
|
+
if (!attempt.ok) {
|
|
62
|
+
if (attempt.failure.cause !== undefined) {
|
|
63
|
+
ctx.reportError(attempt.failure.cause, {
|
|
64
|
+
where: "mii-auth/login",
|
|
65
|
+
step: "sign-in"
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return answer(attempt.failure.kind, STATUS_FOR[attempt.failure.kind]);
|
|
69
|
+
}
|
|
70
|
+
const user = await upsertMemberFromIdentity(ctx, attempt.identity);
|
|
71
|
+
if (user.status !== "active") {
|
|
72
|
+
// A real member of the org whose access to THIS app was turned off here.
|
|
73
|
+
// Plaza has no idea; this is the app's own switch.
|
|
74
|
+
return answer("deactivated", 403);
|
|
75
|
+
}
|
|
76
|
+
await createSession(ctx, user.id);
|
|
77
|
+
return answer(null, 200);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
ctx.reportError(error, { where: "mii-auth/login", step: "session" });
|
|
81
|
+
console.error("mii-auth: sign-in failed on our side", error);
|
|
82
|
+
return answer("unavailable", 503);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The logout route handler. Signs out of this app, and only this app. POST
|
|
87
|
+
* only: a GET that signs you out is a link someone else can make you click.
|
|
88
|
+
*/
|
|
89
|
+
export async function logoutRoute(ctx) {
|
|
90
|
+
await destroySession(ctx);
|
|
91
|
+
return new NextResponse(null, {
|
|
92
|
+
status: 303,
|
|
93
|
+
headers: { Location: "/login" }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Back to the form, carrying only a code — never an address or a password. */
|
|
97
|
+
function loginPath(next, problem) {
|
|
98
|
+
const query = new URLSearchParams({ next, error: problem });
|
|
99
|
+
return `/login?${query.toString()}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A redirect to a path on this app, with no host in it. An absolute one would
|
|
103
|
+
* be built from `request.url`, and inside Plaza's App pane this process is
|
|
104
|
+
* reached on the VM's own loopback address — a machine the browser is not.
|
|
105
|
+
*/
|
|
106
|
+
function seeOther(path) {
|
|
107
|
+
return new NextResponse(null, { status: 303, headers: { Location: path } });
|
|
108
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AuthContext } from "./context.js";
|
|
2
|
+
import { type MiiUser } from "./lookup.js";
|
|
3
|
+
/**
|
|
4
|
+
* The signed-in user, or null. Use in API routes and anywhere "who is this, if
|
|
5
|
+
* anyone" is the question. For pages that need a person, prefer requireUser().
|
|
6
|
+
*/
|
|
7
|
+
export declare function getUser(ctx: AuthContext): Promise<MiiUser | null>;
|
|
8
|
+
/**
|
|
9
|
+
* The signed-in user when the app has a door; null when it is public.
|
|
10
|
+
*
|
|
11
|
+
* Call this in the authenticated layout. If the app is members-only and nobody
|
|
12
|
+
* is signed in, this never returns — it sends the browser to /login. When the
|
|
13
|
+
* app is public it returns null and your pages render for an anonymous visitor.
|
|
14
|
+
*/
|
|
15
|
+
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>;
|
|
18
|
+
/** End the current session, if any, and clear its cookie. */
|
|
19
|
+
export declare function destroySession(ctx: AuthContext): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Cookie attributes for this request.
|
|
22
|
+
*
|
|
23
|
+
* Plaza shows this app inside an iframe on its own page. There the app is a
|
|
24
|
+
* third party, and a Lax cookie is simply not sent — so the pane would sign in
|
|
25
|
+
* forever. Inside a frame the cookie is SameSite=None and *partitioned*: keyed
|
|
26
|
+
* to the page framing it. Opened directly, it is an ordinary Lax first-party
|
|
27
|
+
* cookie. `Sec-Fetch-Dest: iframe` is how the browser tells us which case it is.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cookieOptions(): Promise<{
|
|
30
|
+
httpOnly: boolean;
|
|
31
|
+
path: string;
|
|
32
|
+
sameSite: "none" | "lax";
|
|
33
|
+
secure: boolean;
|
|
34
|
+
partitioned: boolean;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { cookies, headers } from "next/headers";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { hashToken, issueSession, resolveSession, SESSION_COOKIE } from "./lookup.js";
|
|
4
|
+
import { getAuthMode } from "./mode.js";
|
|
5
|
+
import { safeNext } from "./next-path.js";
|
|
6
|
+
/**
|
|
7
|
+
* The signed-in user, or null. Use in API routes and anywhere "who is this, if
|
|
8
|
+
* anyone" is the question. For pages that need a person, prefer requireUser().
|
|
9
|
+
*/
|
|
10
|
+
export async function getUser(ctx) {
|
|
11
|
+
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
|
12
|
+
return token === undefined ? null : resolveSession(ctx, token);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The signed-in user when the app has a door; null when it is public.
|
|
16
|
+
*
|
|
17
|
+
* Call this in the authenticated layout. If the app is members-only and nobody
|
|
18
|
+
* is signed in, this never returns — it sends the browser to /login. When the
|
|
19
|
+
* app is public it returns null and your pages render for an anonymous visitor.
|
|
20
|
+
*/
|
|
21
|
+
export async function requireUser(ctx) {
|
|
22
|
+
const user = await getUser(ctx);
|
|
23
|
+
if (user !== null) {
|
|
24
|
+
return user;
|
|
25
|
+
}
|
|
26
|
+
if ((await getAuthMode()) === "public") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const path = (await headers()).get("x-mii-pathname") ?? "/";
|
|
30
|
+
redirect(`/login?next=${encodeURIComponent(safeNext(path))}`);
|
|
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);
|
|
35
|
+
(await cookies()).set(SESSION_COOKIE, token, {
|
|
36
|
+
...(await cookieOptions()),
|
|
37
|
+
expires: expiresAt
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** End the current session, if any, and clear its cookie. */
|
|
41
|
+
export async function destroySession(ctx) {
|
|
42
|
+
const store = await cookies();
|
|
43
|
+
const token = store.get(SESSION_COOKIE)?.value;
|
|
44
|
+
if (token !== undefined) {
|
|
45
|
+
const sql = await ctx.getSql();
|
|
46
|
+
await sql `delete from mii_auth_sessions where token_hash = ${hashToken(token)}`;
|
|
47
|
+
store.set(SESSION_COOKIE, "", { ...(await cookieOptions()), maxAge: 0 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Cookie attributes for this request.
|
|
52
|
+
*
|
|
53
|
+
* Plaza shows this app inside an iframe on its own page. There the app is a
|
|
54
|
+
* third party, and a Lax cookie is simply not sent — so the pane would sign in
|
|
55
|
+
* forever. Inside a frame the cookie is SameSite=None and *partitioned*: keyed
|
|
56
|
+
* to the page framing it. Opened directly, it is an ordinary Lax first-party
|
|
57
|
+
* cookie. `Sec-Fetch-Dest: iframe` is how the browser tells us which case it is.
|
|
58
|
+
*/
|
|
59
|
+
export async function cookieOptions() {
|
|
60
|
+
const embedded = (await headers()).get("sec-fetch-dest") === "iframe";
|
|
61
|
+
return {
|
|
62
|
+
httpOnly: true,
|
|
63
|
+
path: "/",
|
|
64
|
+
sameSite: embedded ? "none" : "lax",
|
|
65
|
+
secure: embedded || process.env.NODE_ENV === "production",
|
|
66
|
+
partitioned: embedded
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type UsersConfig = {
|
|
2
|
+
/** The table. Quoted by the kit, so `"user"` is fine. */
|
|
3
|
+
table?: string;
|
|
4
|
+
/**
|
|
5
|
+
* Who makes a new row's id. "database" — the column has a default (uuid).
|
|
6
|
+
* "kit" — the column has none (Better Auth's text ids), so the kit passes one.
|
|
7
|
+
*/
|
|
8
|
+
idFrom?: "database" | "kit";
|
|
9
|
+
/** Column holding the avatar URL. */
|
|
10
|
+
avatarColumn?: string;
|
|
11
|
+
/** Column stamped on every sign-in. */
|
|
12
|
+
lastLoginColumn?: string;
|
|
13
|
+
/** Columns a fresh row needs that Plaza does not supply, with their values. */
|
|
14
|
+
insertDefaults?: Record<string, string | number | boolean>;
|
|
15
|
+
/** The table's `email` is NOT NULL, so the kit fills one in when needed. */
|
|
16
|
+
emailRequired?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Rows created before the person had a mii id (an app older than the kit) are
|
|
19
|
+
* matched by address on first sign-in and given their mii id, not duplicated.
|
|
20
|
+
*/
|
|
21
|
+
adoptByEmail?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Run the kit's own CREATE TABLE IF NOT EXISTS on first use. Only right for
|
|
24
|
+
* the default table; an app-owned table has its own migrations.
|
|
25
|
+
*/
|
|
26
|
+
ensureSchema?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type ResolvedUsers = Required<UsersConfig>;
|
|
29
|
+
export declare function resolveUsers(config: UsersConfig | undefined): ResolvedUsers;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Where an app keeps its people. This used to be `src/lib/mii-auth/tables.ts`,
|
|
2
|
+
// the one file the kit was forbidden to overwrite — because the kit's logic is
|
|
3
|
+
// the platform's, but which table holds the users is the app's. As a package,
|
|
4
|
+
// that seam is a config argument to `createMiiAuth` instead of a file the sync
|
|
5
|
+
// had to carve out and never touch.
|
|
6
|
+
//
|
|
7
|
+
// Three apps (CBRE Toronto, Newmark SF, WBP) kept Better Auth's `"user"` table
|
|
8
|
+
// as their own; they pass `{ table: "user", idFrom: "kit", ... }`. Everyone
|
|
9
|
+
// else takes the default (`mii_auth_users`, shipped by migrations/0004).
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
table: "mii_auth_users",
|
|
12
|
+
idFrom: "database",
|
|
13
|
+
avatarColumn: "avatar_url",
|
|
14
|
+
lastLoginColumn: "last_login_at",
|
|
15
|
+
insertDefaults: {},
|
|
16
|
+
emailRequired: false,
|
|
17
|
+
adoptByEmail: false,
|
|
18
|
+
ensureSchema: true
|
|
19
|
+
};
|
|
20
|
+
export function resolveUsers(config) {
|
|
21
|
+
return { ...DEFAULTS, ...(config ?? {}) };
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AuthContext } from "./context.js";
|
|
2
|
+
import type { MiiIdentity } from "./plaza.js";
|
|
3
|
+
export declare function upsertMemberFromIdentity(ctx: AuthContext, identity: MiiIdentity): Promise<{
|
|
4
|
+
id: string;
|
|
5
|
+
status: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** Deactivate a user: their sessions stop working on the next request. */
|
|
8
|
+
export declare function deactivateUser(ctx: AuthContext, userId: string): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
// The user row behind a sign-in. Created the first time someone arrives,
|
|
3
|
+
// refreshed on every visit from what Plaza says now (name, email, avatar), and
|
|
4
|
+
// keyed on the mii id — the one thing about a person that never changes.
|
|
5
|
+
//
|
|
6
|
+
// Which table, and its shape, is the app's (UsersConfig). An app that had
|
|
7
|
+
// people before it had the kit may hold rows with no mii id yet; those are
|
|
8
|
+
// claimed by address on first sign-in rather than duplicated beside a new row.
|
|
9
|
+
export async function upsertMemberFromIdentity(ctx, identity) {
|
|
10
|
+
const sql = await ctx.getSql();
|
|
11
|
+
const { users } = ctx;
|
|
12
|
+
const table = sql(users.table);
|
|
13
|
+
const avatar = sql(users.avatarColumn);
|
|
14
|
+
const lastLogin = sql(users.lastLoginColumn);
|
|
15
|
+
// A NOT NULL address column and a person with none: something unique that
|
|
16
|
+
// reads as what it is, rather than a refused sign-in.
|
|
17
|
+
const email = identity.email ?? (users.emailRequired ? `${identity.miiId}@no-email.mii` : null);
|
|
18
|
+
const [known] = await sql `
|
|
19
|
+
update ${table} set
|
|
20
|
+
email = ${email},
|
|
21
|
+
name = ${identity.name},
|
|
22
|
+
${avatar} = ${identity.avatarUrl},
|
|
23
|
+
${lastLogin} = now()
|
|
24
|
+
where mii_id = ${identity.miiId}
|
|
25
|
+
returning id, status`;
|
|
26
|
+
if (known !== undefined)
|
|
27
|
+
return known;
|
|
28
|
+
if (users.adoptByEmail && identity.email !== null) {
|
|
29
|
+
const [adopted] = await sql `
|
|
30
|
+
update ${table} set
|
|
31
|
+
mii_id = ${identity.miiId},
|
|
32
|
+
name = ${identity.name},
|
|
33
|
+
${avatar} = ${identity.avatarUrl},
|
|
34
|
+
${lastLogin} = now()
|
|
35
|
+
where mii_id is null and lower(email) = lower(${identity.email})
|
|
36
|
+
returning id, status`;
|
|
37
|
+
if (adopted !== undefined)
|
|
38
|
+
return adopted;
|
|
39
|
+
}
|
|
40
|
+
const row = {
|
|
41
|
+
...users.insertDefaults,
|
|
42
|
+
...(users.idFrom === "kit" ? { id: randomUUID() } : {}),
|
|
43
|
+
mii_id: identity.miiId,
|
|
44
|
+
email,
|
|
45
|
+
name: identity.name,
|
|
46
|
+
[users.avatarColumn]: identity.avatarUrl,
|
|
47
|
+
[users.lastLoginColumn]: new Date()
|
|
48
|
+
};
|
|
49
|
+
const [created] = await sql `
|
|
50
|
+
insert into ${table} ${sql(row)}
|
|
51
|
+
returning id, status`;
|
|
52
|
+
return created;
|
|
53
|
+
}
|
|
54
|
+
/** Deactivate a user: their sessions stop working on the next request. */
|
|
55
|
+
export async function deactivateUser(ctx, userId) {
|
|
56
|
+
const sql = await ctx.getSql();
|
|
57
|
+
await sql `update ${sql(ctx.users.table)} set status = 'inactive' where id = ${userId}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Plaza origins allowed to frame an org app. Keep in step with the pane. */
|
|
2
|
+
export declare const PLAZA_FRAME_ORIGINS: readonly ["https://plaza.miis.run", "https://miiplaza.vercel.app", "http://localhost:3000", "http://localhost:3111"];
|
|
3
|
+
type Header = {
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
};
|
|
7
|
+
type HeaderRule = {
|
|
8
|
+
source: string;
|
|
9
|
+
headers: Header[];
|
|
10
|
+
[k: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
type NextConfigLike = {
|
|
13
|
+
headers?: () => Promise<HeaderRule[]>;
|
|
14
|
+
[k: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
export declare function withMiiPlatform(config?: NextConfigLike): NextConfigLike;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The Next.js config every mii org app must not diverge from — the framing
|
|
2
|
+
// contract with Plaza's App pane. An app composes its own config through this
|
|
3
|
+
// wrapper; the platform's requirements are force-merged on top so an app cannot
|
|
4
|
+
// accidentally forbid Plaza from embedding it (which shows as a white pane).
|
|
5
|
+
//
|
|
6
|
+
// // next.config.mjs
|
|
7
|
+
// import { withMiiPlatform } from "@shubh90/app-runtime/next";
|
|
8
|
+
// export default withMiiPlatform({ /* the app's own config */ });
|
|
9
|
+
//
|
|
10
|
+
// Newmark SF set `frame-ancestors 'none'` + `X-Frame-Options: DENY` in its own
|
|
11
|
+
// config and white-screened its pane. This makes that impossible: whatever the
|
|
12
|
+
// app sets, the resulting headers frame-allow Plaza and carry no X-Frame-Options.
|
|
13
|
+
/** Plaza origins allowed to frame an org app. Keep in step with the pane. */
|
|
14
|
+
export const PLAZA_FRAME_ORIGINS = [
|
|
15
|
+
"https://plaza.miis.run",
|
|
16
|
+
"https://miiplaza.vercel.app",
|
|
17
|
+
"http://localhost:3000",
|
|
18
|
+
"http://localhost:3111"
|
|
19
|
+
];
|
|
20
|
+
const FRAME_ANCESTORS = `frame-ancestors 'self' ${PLAZA_FRAME_ORIGINS.join(" ")}`;
|
|
21
|
+
/** Force `frame-ancestors` to allow Plaza in a Content-Security-Policy value. */
|
|
22
|
+
function forceFrameAncestors(csp) {
|
|
23
|
+
const directives = csp
|
|
24
|
+
.split(";")
|
|
25
|
+
.map((d) => d.trim())
|
|
26
|
+
.filter((d) => d !== "" && !d.toLowerCase().startsWith("frame-ancestors"));
|
|
27
|
+
directives.push(FRAME_ANCESTORS);
|
|
28
|
+
return directives.join("; ");
|
|
29
|
+
}
|
|
30
|
+
/** Rewrite one route's headers: fix any CSP's frame-ancestors, drop XFO. */
|
|
31
|
+
function enforce(headers) {
|
|
32
|
+
let sawCsp = false;
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const h of headers) {
|
|
35
|
+
const key = h.key.toLowerCase();
|
|
36
|
+
if (key === "x-frame-options")
|
|
37
|
+
continue; // cannot allowlist; CSP governs
|
|
38
|
+
if (key === "content-security-policy") {
|
|
39
|
+
sawCsp = true;
|
|
40
|
+
out.push({ key: h.key, value: forceFrameAncestors(h.value) });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out.push(h);
|
|
44
|
+
}
|
|
45
|
+
if (!sawCsp) {
|
|
46
|
+
out.push({ key: "Content-Security-Policy", value: FRAME_ANCESTORS });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function withMiiPlatform(config = {}) {
|
|
51
|
+
const appHeaders = config.headers;
|
|
52
|
+
return {
|
|
53
|
+
...config,
|
|
54
|
+
async headers() {
|
|
55
|
+
const rules = appHeaders ? await appHeaders() : [];
|
|
56
|
+
const enforced = rules.map((rule) => ({
|
|
57
|
+
...rule,
|
|
58
|
+
headers: enforce(rule.headers)
|
|
59
|
+
}));
|
|
60
|
+
// Guarantee coverage even if the app declared no header rules at all.
|
|
61
|
+
if (!enforced.some((r) => r.source === "/:path*")) {
|
|
62
|
+
enforced.push({
|
|
63
|
+
source: "/:path*",
|
|
64
|
+
headers: [{ key: "Content-Security-Policy", value: FRAME_ANCESTORS }]
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return enforced;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shubh90/app-runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The platform contract every mii org app depends on \u2014 sign-in, and the Next.js config an app must not diverge from. A versioned package, not files copied into each repo.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/shubh9/mii.git",
|
|
13
|
+
"directory": "packages/app-runtime"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
"./auth": {
|
|
20
|
+
"types": "./dist/auth/index.d.ts",
|
|
21
|
+
"import": "./dist/auth/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./next": {
|
|
24
|
+
"types": "./dist/next/index.d.ts",
|
|
25
|
+
"import": "./dist/next/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
32
|
+
"check": "npm run typecheck && npm run test"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"next": ">=15",
|
|
36
|
+
"postgres": ">=3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22",
|
|
40
|
+
"next": "^15",
|
|
41
|
+
"postgres": "^3.4.9",
|
|
42
|
+
"tsx": "^4",
|
|
43
|
+
"typescript": "^5.6"
|
|
44
|
+
}
|
|
45
|
+
}
|