@columenlabs/lumen-people 0.0.0-stage → 0.1.67

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Columen Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,12 @@
1
+ Lumen
2
+ Copyright (c) 2026 Columen Labs. Licensed under the MIT License (see LICENSE).
3
+
4
+ Lumen draws price and market charts with TradingView Lightweight Charts™
5
+ (https://github.com/tradingview/lightweight-charts), Copyright (c) 2025
6
+ TradingView, Inc., licensed under the Apache License 2.0. Lumen turns off the
7
+ chart's own logo, so per that library's NOTICE, an app that shows these charts
8
+ should link to https://www.tradingview.com/ somewhere a user can find it (an
9
+ About page or footer is enough).
10
+
11
+ The components under packages/ui/src/ui are based on shadcn/ui
12
+ (https://ui.shadcn.com), MIT License, Copyright (c) 2023 shadcn.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @columenlabs/lumen-people
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lumen's people, access and invites, server side: who may sign in, what each person may do in each part of an app, invites with a single-use link that expires, and an audit log of every change. It pairs with the People screens in `@columenlabs/lumen-ui/people`. No dependencies beyond `@columenlabs/lumen-ui-types`: Web Crypto, `Request` and `Response`, so it runs in a Worker, a Durable Object, Bun or Node.
4
+
5
+ - Source: https://github.com/columenlabs/lumen (`packages/people`). MIT.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @columenlabs/lumen-people @columenlabs/lumen-ui-types
11
+ ```
12
+
13
+ Pin both to the same exact version as the other Lumen packages.
14
+
15
+ ## Scopes and levels
16
+
17
+ A scope is anything access is given on: an app, or a construct inside one (a folder, a collection). Each has its own ordered levels, lowest first (`None, View, Edit, Admin` when it names none). A person holds one level per scope; no grant is the lowest. Admins hold the top level everywhere; disabled people and expired invites hold the lowest.
18
+
19
+ ```ts
20
+ import type { Scope } from '@columenlabs/lumen-ui-types/people';
21
+
22
+ export const SCOPES: Scope[] = [
23
+ { id: 'tools', name: 'Tools' },
24
+ { id: 'recipes', name: 'Recipes' },
25
+ { id: 'collections', name: 'Collections', parent: 'recipes', resourceLabel: 'Collection' },
26
+ ];
27
+ ```
28
+
29
+ ## The service
30
+
31
+ ```ts
32
+ import { can, createPeople, doSql, sqlStore } from '@columenlabs/lumen-people';
33
+
34
+ // In a Durable Object, so the admin floor check and its write never interleave:
35
+ const people = createPeople({
36
+ store: sqlStore(doSql(ctx.storage.sql)), // or sqlStore(d1Sql(env.DB))
37
+ scopes: SCOPES,
38
+ fixedAdmins: ['owner@example.com'], // always an active admin
39
+ inviteDays: 7,
40
+ });
41
+
42
+ const person = await people.seen('a@example.com'); // on each sign-in: null = refuse
43
+ can(person, SCOPES[0], 'view', SCOPES); // the one check a route makes
44
+ ```
45
+
46
+ The rules it keeps:
47
+
48
+ - One active admin always; the built-in admins can never lose it.
49
+ - Only an admin invites, changes access or removes; refusals are logged too.
50
+ - An invite link is single use; only its SHA-256 hash is stored. Resend makes a new link and restarts the clock, revoke kills it. An invite past its life is refused at sign-in until resent.
51
+ - Every change is one audit event (who, what, when, from where), kept a year.
52
+
53
+ ## The routes
54
+
55
+ ```ts
56
+ import { peopleRoutes } from '@columenlabs/lumen-people/routes';
57
+
58
+ const routes = peopleRoutes({
59
+ base: '/api/people',
60
+ people: () => people, // or a Durable Object stub
61
+ actor: (req) => ({ email, admin, via: 'app' }), // your verified caller
62
+ scopes: SCOPES,
63
+ appName: 'Example',
64
+ link: (token) => `https://app.example.com/invite/${token}`,
65
+ mailer: () => myMailer, // optional: (msg) => { ok, detail }
66
+ onRemove: async (person) => { /* delete their data */ },
67
+ });
68
+
69
+ app.all('/api/people/*', (c) => routes(c.req.raw));
70
+ ```
71
+
72
+ Without a mailer every invite returns its link to copy by hand; a failed send never undoes an invite.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Email through a Resend-style API: `POST {url}/emails` with `Authorization: Bearer <key>`, which
3
+ * Resend and self-hosted servers that copy its API both answer. No SMTP. The app keeps the settings
4
+ * (sealed: the key never comes back) and says how each route is reached, e.g. a private tunnel's
5
+ * own fetch; the kit sends, checks and tests.
6
+ */
7
+ import type { EmailCheck, EmailDraft, EmailRoute, EmailSettingsView, MailOutcome } from '@columenlabs/lumen-ui-types/people';
8
+ import type { MailMessage, Mailer } from './mailer.js';
9
+ type Fetch = (req: Request) => Promise<Response>;
10
+ type Awaitable<T> = T | Promise<T>;
11
+ export interface ResendConfig {
12
+ /** The API base without `/emails`: `https://api.resend.com`, `http://localhost:5000/api`. */
13
+ url: string;
14
+ apiKey: string;
15
+ from: string;
16
+ fromName?: string;
17
+ /** How requests reach the server (a tunnel binding's fetch); the global fetch by default. */
18
+ fetch?: Fetch;
19
+ timeoutMs?: number;
20
+ }
21
+ /** `https://api.resend.com/emails/` → `https://api.resend.com`; null for anything but a plain URL. */
22
+ export declare function emailApiUrl(v: string): string | null;
23
+ /** One email; never throws. */
24
+ export declare function sendResend(c: ResendConfig, msg: MailMessage): Promise<MailOutcome>;
25
+ export declare const resendMailer: (c: ResendConfig) => Mailer;
26
+ /** Check connection: every step but sending. */
27
+ export declare function checkResend(c: ResendConfig, where: string): Promise<EmailCheck>;
28
+ /** Send test: a real email to `to`. */
29
+ export declare function testResend(c: ResendConfig, where: string, to: string, appName: string): Promise<EmailCheck>;
30
+ /** What the app stores: the draft with its key, sealed by the app. */
31
+ export interface StoredEmail {
32
+ route: string;
33
+ url: string;
34
+ apiKey: string;
35
+ from: string;
36
+ fromName: string;
37
+ }
38
+ export interface EmailRouteDef extends EmailRoute {
39
+ /** Why an address will not do on this route (a tunnel that allows one port), or null. */
40
+ checkUrl?: (url: string) => string | null;
41
+ /** How this route reaches the server; the global fetch by default (then https only). */
42
+ fetch?: (req: Request) => Awaitable<Fetch | null>;
43
+ /** The server named in check lines ("the tunnel's machine"); the address's host by default. */
44
+ where?: string;
45
+ }
46
+ export interface EmailOptions {
47
+ routes: EmailRouteDef[];
48
+ store: {
49
+ get: (req: Request) => Promise<{
50
+ settings: StoredEmail;
51
+ updatedAt: number | null;
52
+ updatedBy: string | null;
53
+ } | null>;
54
+ put: (req: Request, settings: StoredEmail, by: string) => Promise<void>;
55
+ clear?: (req: Request) => Promise<void>;
56
+ lastTest?: (req: Request) => Promise<EmailCheck | null>;
57
+ putTest?: (req: Request, test: EmailCheck) => Promise<void>;
58
+ };
59
+ }
60
+ /** The draft as settings to try, with the saved key when the draft left it out; or why not. */
61
+ export declare function resolveDraft(o: EmailOptions, req: Request, draft: Partial<EmailDraft>): Promise<{
62
+ ok: true;
63
+ settings: StoredEmail;
64
+ config: ResendConfig;
65
+ where: string;
66
+ } | {
67
+ ok: false;
68
+ error: string;
69
+ }>;
70
+ /** The saved settings as a mailer, or null when none are saved or their route is gone. */
71
+ export declare function storedMailer(o: EmailOptions, req: Request): Promise<{
72
+ mailer: Mailer;
73
+ from: string;
74
+ } | null>;
75
+ export declare function emailView(o: EmailOptions, req: Request, me: string): Promise<EmailSettingsView>;
76
+ export {};
package/dist/email.js ADDED
@@ -0,0 +1,210 @@
1
+ const TIMEOUT_MS = 15_000;
2
+ /** `https://api.resend.com/emails/` → `https://api.resend.com`; null for anything but a plain URL. */
3
+ export function emailApiUrl(v) {
4
+ try {
5
+ const u = new URL(v.trim());
6
+ if (u.search || u.hash || u.username || u.password)
7
+ return null;
8
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
9
+ return null;
10
+ return `${u.origin}${u.pathname}`.replace(/\/+$/, '').replace(/\/emails$/, '');
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ const fromHeader = (c) => c.fromName ? `"${c.fromName.replace(/["<>]/g, '')}" <${c.from}>` : c.from;
17
+ async function call(c, path, init) {
18
+ const t0 = Date.now();
19
+ const req = new Request(`${c.url}${path}`, {
20
+ ...init,
21
+ headers: { authorization: `Bearer ${c.apiKey}`, 'content-type': 'application/json' },
22
+ redirect: 'manual',
23
+ signal: AbortSignal.timeout(c.timeoutMs ?? TIMEOUT_MS),
24
+ });
25
+ try {
26
+ const res = await (c.fetch ?? ((r) => fetch(r)))(req);
27
+ return { res, ms: Date.now() - t0 };
28
+ }
29
+ catch {
30
+ return { res: null, ms: Date.now() - t0 };
31
+ }
32
+ }
33
+ const refused = (status) => status === 401 || status === 403
34
+ ? 'The server refused the API key'
35
+ : status === 400 || status === 422
36
+ ? 'The server refused the email (check the sender)'
37
+ : status === 429
38
+ ? 'The server is rate limiting; try again soon'
39
+ : `The server answered ${status}`;
40
+ /** One email; never throws. */
41
+ export async function sendResend(c, msg) {
42
+ const { res } = await call(c, '/emails', {
43
+ method: 'POST',
44
+ body: JSON.stringify({
45
+ from: fromHeader(c),
46
+ to: [msg.to],
47
+ subject: msg.subject,
48
+ html: msg.html,
49
+ text: msg.text,
50
+ }),
51
+ });
52
+ if (!res)
53
+ return { ok: false, detail: 'Could not reach the email server' };
54
+ await res.body?.cancel();
55
+ return res.ok
56
+ ? { ok: true, detail: `Emailed ${msg.to}` }
57
+ : { ok: false, detail: refused(res.status) };
58
+ }
59
+ export const resendMailer = (c) => (msg) => sendResend(c, msg);
60
+ /** Reached the server and key accepted, from one request that cannot send: an empty email to
61
+ * `/emails`. A refused key answers 401 or 403; a key that passes gets the empty email refused. */
62
+ async function reachAndKey(c, where) {
63
+ const { res, ms } = await call(c, '/emails', { method: 'POST', body: '{}' });
64
+ if (!res)
65
+ return [{ id: 'reach', ok: false, text: `Could not reach ${where}`, ms }];
66
+ await res.body?.cancel();
67
+ const reach = { id: 'reach', ok: true, text: `Reached ${where}`, ms };
68
+ if (res.status === 404 || res.status === 405)
69
+ return [{ ...reach, ok: false, text: `${where} has no email API at this address` }];
70
+ if (res.status === 401 || res.status === 403)
71
+ return [reach, { id: 'key', ok: false, text: 'The server refused the API key', ms: null }];
72
+ if (res.status >= 500)
73
+ return [reach, { id: 'key', ok: false, text: `The server answered ${res.status}`, ms: null }];
74
+ if (res.status === 429)
75
+ return [reach, { id: 'key', ok: null, text: 'Rate limited: try again soon', ms: null }];
76
+ return [reach, { id: 'key', ok: true, text: 'API key accepted', ms: null }];
77
+ }
78
+ /** The sender's domain among the server's verified domains (`GET /domains`), where it lists them. */
79
+ async function sender(c) {
80
+ const { res, ms } = await call(c, '/domains', { method: 'GET' });
81
+ const unknown = {
82
+ id: 'sender',
83
+ ok: null,
84
+ text: `${c.from}: this server does not list its senders, so Send test confirms it`,
85
+ ms,
86
+ };
87
+ if (!res?.ok) {
88
+ await res?.body?.cancel();
89
+ return unknown;
90
+ }
91
+ const body = (await res.json().catch(() => null));
92
+ if (!Array.isArray(body?.data))
93
+ return unknown;
94
+ const domain = c.from.split('@')[1]?.toLowerCase();
95
+ const d = body.data.find((x) => x.name?.toLowerCase() === domain);
96
+ if (!d)
97
+ return { id: 'sender', ok: false, text: `${domain} is not a domain on this server`, ms };
98
+ const ok = !d.status || d.status === 'verified';
99
+ return {
100
+ id: 'sender',
101
+ ok,
102
+ text: ok ? `${c.from} is allowed to send` : `${domain} is ${d.status} on this server`,
103
+ ms,
104
+ };
105
+ }
106
+ const passed = (steps) => steps.every((s) => s.ok !== false) && steps.some((s) => s.ok === true);
107
+ /** Check connection: every step but sending. */
108
+ export async function checkResend(c, where) {
109
+ const steps = await reachAndKey(c, where);
110
+ if (passed(steps))
111
+ steps.push(await sender(c));
112
+ return {
113
+ ok: passed(steps) && steps.some((s) => s.id === 'key' && s.ok),
114
+ kind: 'check',
115
+ steps,
116
+ at: Date.now(),
117
+ };
118
+ }
119
+ /** Send test: a real email to `to`. */
120
+ export async function testResend(c, where, to, appName) {
121
+ const t0 = Date.now();
122
+ const r = await sendResend(c, {
123
+ to,
124
+ subject: `${appName} email test`,
125
+ html: `<!doctype html><html><body style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5"><p>This is a test from ${appName} settings.</p><p style="color:#78716c;font-size:13px">Sent as ${c.from}.</p></body></html>`,
126
+ text: `This is a test from ${appName} settings. Sent as ${c.from}.`,
127
+ });
128
+ const ms = Date.now() - t0;
129
+ const steps = r.ok
130
+ ? [
131
+ { id: 'reach', ok: true, text: `Reached ${where}`, ms: null },
132
+ { id: 'key', ok: true, text: 'API key accepted', ms: null },
133
+ { id: 'send', ok: true, text: `Sent to ${to} · check the inbox`, ms },
134
+ ]
135
+ : [{ id: 'send', ok: false, text: r.detail, ms }];
136
+ return { ok: r.ok, kind: 'test', steps, at: Date.now() };
137
+ }
138
+ const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
139
+ /** The draft as settings to try, with the saved key when the draft left it out; or why not. */
140
+ export async function resolveDraft(o, req, draft) {
141
+ const route = o.routes.find((r) => r.id === draft.route);
142
+ if (!route)
143
+ return { ok: false, error: 'Pick how to reach the server.' };
144
+ const url = emailApiUrl(String(draft.url ?? ''));
145
+ if (!url)
146
+ return { ok: false, error: 'Enter the server address, like https://api.resend.com.' };
147
+ const own = route.checkUrl?.(url);
148
+ if (own)
149
+ return { ok: false, error: own };
150
+ const f = route.fetch ? await route.fetch(req) : null;
151
+ if (route.fetch && !f)
152
+ return { ok: false, error: `${route.label} is not connected.` };
153
+ if (!route.fetch && !url.startsWith('https://'))
154
+ return { ok: false, error: 'Use an https address on the public internet.' };
155
+ const saved = (await o.store.get(req))?.settings;
156
+ const apiKey = String(draft.apiKey ?? '').trim() || saved?.apiKey || '';
157
+ if (!apiKey || apiKey.length > 300)
158
+ return { ok: false, error: 'Enter the API key.' };
159
+ const from = String(draft.from ?? '')
160
+ .trim()
161
+ .toLowerCase();
162
+ if (!EMAIL_RE.test(from))
163
+ return { ok: false, error: 'Enter the address mail is sent from.' };
164
+ const fromName = String(draft.fromName ?? '')
165
+ .trim()
166
+ .slice(0, 80);
167
+ const settings = { route: route.id, url, apiKey, from, fromName };
168
+ return {
169
+ ok: true,
170
+ settings,
171
+ config: { url, apiKey, from, fromName, ...(f ? { fetch: f } : {}) },
172
+ where: route.where ?? new URL(url).host,
173
+ };
174
+ }
175
+ /** The saved settings as a mailer, or null when none are saved or their route is gone. */
176
+ export async function storedMailer(o, req) {
177
+ const s = (await o.store.get(req))?.settings;
178
+ if (!s)
179
+ return null;
180
+ const r = await resolveDraft(o, req, s);
181
+ return r.ok
182
+ ? { mailer: resendMailer(r.config), from: s.fromName ? `${s.fromName} <${s.from}>` : s.from }
183
+ : null;
184
+ }
185
+ export async function emailView(o, req, me) {
186
+ const [got, lastTest] = await Promise.all([o.store.get(req), o.store.lastTest?.(req) ?? null]);
187
+ const current = got
188
+ ? {
189
+ route: got.settings.route,
190
+ url: got.settings.url,
191
+ keyHint: `…${got.settings.apiKey.slice(-4)}`,
192
+ from: got.settings.from,
193
+ fromName: got.settings.fromName,
194
+ updatedAt: got.updatedAt,
195
+ updatedBy: got.updatedBy,
196
+ }
197
+ : null;
198
+ return {
199
+ routes: o.routes.map(({ id, label, state, urlInfo, url }) => ({
200
+ id,
201
+ label,
202
+ state,
203
+ urlInfo,
204
+ url,
205
+ })),
206
+ current,
207
+ lastTest,
208
+ me,
209
+ };
210
+ }
@@ -0,0 +1,6 @@
1
+ export { type Actor, createPeople, type InviteOutcome, type PeopleOptions, type PeopleService, type Result, } from './service.js';
2
+ export { d1Sql, doSql, type PeopleStore, type SqlRunner, sqlStore, type StoredPerson, } from './store.js';
3
+ export { type InviteMail, type InviteTemplate, inviteTemplate, type MailMessage, type Mailer, } from './mailer.js';
4
+ export { type PeopleApi, type PeopleRoutesOptions, peopleRoutes } from './routes.js';
5
+ export { atLeast, can, checkAdminFloor, DEFAULT_LEVELS, levelOf, normEmail, } from '@columenlabs/lumen-ui-types/people';
6
+ export { checkResend, type EmailOptions, type EmailRouteDef, emailApiUrl, type ResendConfig, resendMailer, sendResend, type StoredEmail, testResend, } from './email.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // Lumen's people, access and invites on the server. The screens are `@columenlabs/lumen-ui/people`;
2
+ // the shapes and pure rules both share are `@columenlabs/lumen-ui-types/people`.
3
+ export { createPeople, } from './service.js';
4
+ export { d1Sql, doSql, sqlStore, } from './store.js';
5
+ export { inviteTemplate, } from './mailer.js';
6
+ export { peopleRoutes } from './routes.js';
7
+ export { atLeast, can, checkAdminFloor, DEFAULT_LEVELS, levelOf, normEmail, } from '@columenlabs/lumen-ui-types/people';
8
+ export { checkResend, emailApiUrl, resendMailer, sendResend, testResend, } from './email.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The mailer is the app's: any function that sends one email and says whether it went. The
3
+ * people routes only build the invite email and hand it over, so an app plugs in whatever it
4
+ * sends with (an email API, an SMTP relay, a queue). With no mailer, every invite gives a link
5
+ * to send by hand.
6
+ */
7
+ import type { MailOutcome } from '@columenlabs/lumen-ui-types/people';
8
+ export interface MailMessage {
9
+ to: string;
10
+ subject: string;
11
+ html: string;
12
+ text: string;
13
+ }
14
+ export type Mailer = (message: MailMessage) => Promise<MailOutcome>;
15
+ export interface InviteMail {
16
+ to: string;
17
+ /** The app's name, as the person knows it. */
18
+ appName: string;
19
+ /** Who invited them (a name or an email). */
20
+ inviter: string;
21
+ /** The single-use link. */
22
+ url: string;
23
+ /** What they get, in words: `Notes (View), Billing (Admin)`. */
24
+ access: string[];
25
+ expiresAt: number | null;
26
+ /** The inviter's own line. */
27
+ note?: string;
28
+ }
29
+ /** Builds the invite email; an app passes its own to change the words or the look. */
30
+ export type InviteTemplate = (i: InviteMail) => Pick<MailMessage, 'subject' | 'html' | 'text'>;
31
+ export declare const inviteTemplate: InviteTemplate;
package/dist/mailer.js ADDED
@@ -0,0 +1,21 @@
1
+ const esc = (s) => s.replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch] ?? ch);
2
+ const day = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
3
+ export const inviteTemplate = (i) => {
4
+ const access = i.access.length ? `You get ${i.access.join(', ')}.` : '';
5
+ const until = i.expiresAt ? `The link works until ${day(i.expiresAt)}.` : '';
6
+ const text = [
7
+ `${i.inviter} invited you to ${i.appName}.`,
8
+ access,
9
+ i.note ? `"${i.note}"` : '',
10
+ `Accept: ${i.url}`,
11
+ `${until} Sign in with ${i.to}. Not expecting this? Ignore it.`,
12
+ ]
13
+ .filter(Boolean)
14
+ .join('\n\n');
15
+ const html = `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:15px;line-height:1.5;color:#1c1917;max-width:520px;margin:0 auto;padding:24px">
16
+ <p><strong>${esc(i.inviter)}</strong> invited you to ${esc(i.appName)}.</p>${access ? `\n<p>${esc(access)}</p>` : ''}${i.note ? `\n<p style="border-left:3px solid #d6d3d1;padding-left:12px;color:#57534e">${esc(i.note)}</p>` : ''}
17
+ <p style="margin:24px 0"><a href="${esc(i.url)}" style="background:#1c1917;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-weight:600">Accept invite</a></p>
18
+ <p style="color:#78716c;font-size:13px">${esc(until)} Sign in with ${esc(i.to)}. Not expecting this? Ignore it.</p>
19
+ </body></html>`;
20
+ return { subject: `${i.inviter} invited you to ${i.appName}`, html, text };
21
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The HTTP routes the People screens call (`peopleClient(url)` in `@columenlabs/lumen-ui/people`),
3
+ * as one fetch handler on Web `Request`/`Response`, so it mounts in any router:
4
+ *
5
+ * app.all('/api/people/*', (c) => people(c.req.raw)); // Hono
6
+ *
7
+ * Everything but `GET /me` and the invitee's `/invite/:token` routes needs an admin. Writes must
8
+ * be same-origin JSON: a signed-in browser session is a cookie, so a foreign page's form or fetch
9
+ * is refused before it reaches a rule.
10
+ */
11
+ import { type Person, type RemovePlan, type Scope, type SyncStatus } from '@columenlabs/lumen-ui-types/people';
12
+ import { type EmailOptions } from './email.js';
13
+ import { type InviteTemplate, type Mailer } from './mailer.js';
14
+ import type { Actor, PeopleService } from './service.js';
15
+ type Awaitable<T> = T | Promise<T>;
16
+ type Methods = 'list' | 'invite' | 'resend' | 'revoke' | 'update' | 'setAccess' | 'removable' | 'remove' | 'linkFor' | 'accept' | 'audit' | 'log';
17
+ /** The service's calls the routes make: the service itself, or a stub that forwards to it
18
+ * (a Durable Object that runs it). */
19
+ export type PeopleApi = {
20
+ [K in Methods]: PeopleService[K];
21
+ };
22
+ export interface PeopleRoutesOptions {
23
+ /** Where the routes are mounted (`/api/people`). */
24
+ base: string;
25
+ /** The people service for this request. */
26
+ people: (req: Request) => Awaitable<PeopleApi>;
27
+ /** The verified caller, or null (403). The app decides who is an admin. */
28
+ actor: (req: Request) => Awaitable<Actor | null>;
29
+ /** The same scopes, link life and built-in admins the service was made with. */
30
+ scopes: readonly Scope[];
31
+ inviteDays?: number;
32
+ fixedAdmins?: readonly string[];
33
+ /** The app's name in the invite email. */
34
+ appName: string;
35
+ /** The page an invite link opens, with its token (`https://app.example.com/invite/<token>`). */
36
+ link: (token: string, req: Request) => string;
37
+ /** Sends an email; null or left out: no mail, every invite gives its link instead. */
38
+ mailer?: (req: Request) => Awaitable<Mailer | null>;
39
+ /** Shown beside Send in the invite drawer (`hello@example.com`). */
40
+ mailFrom?: (req: Request) => Awaitable<string | undefined>;
41
+ template?: InviteTemplate;
42
+ /** What removing a person does in the app, shown before Remove (one line per app part). */
43
+ removeSteps?: (person: Person, req: Request) => Awaitable<RemovePlan['steps']>;
44
+ /** The app's own clean-up, before the person is dropped (delete their data). */
45
+ onRemove?: (person: Person, req: Request) => Promise<void>;
46
+ /** Email through a Resend-style API, set in the app's settings (`/email` routes); also the
47
+ * invites' mailer when `mailer` is left out. */
48
+ email?: EmailOptions;
49
+ /** Keeps an outside sign-in provider's allow list in step; run after every change to who may sign in. */
50
+ sync?: {
51
+ run: (req: Request, by: string) => Promise<SyncStatus>;
52
+ status: (req: Request) => Promise<SyncStatus | null>;
53
+ };
54
+ }
55
+ export declare function peopleRoutes(o: PeopleRoutesOptions): (req: Request) => Promise<Response>;
56
+ export {};