@volter/twin-openai 0.1.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +33 -30
  2. package/defaults/handlers.json +10 -0
  3. package/dist/defaults/handlers.json +10 -0
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +29 -0
  6. package/dist/src/generated/surface.gen.json +1 -0
  7. package/dist/src/generated/ui.gen.json +1 -0
  8. package/dist/src/index.d.ts +19 -0
  9. package/dist/src/index.js +72 -0
  10. package/dist/src/manifest.d.ts +6 -0
  11. package/dist/src/manifest.js +323 -0
  12. package/dist/src/openai-budget.d.ts +53 -0
  13. package/dist/src/openai-budget.js +147 -0
  14. package/dist/src/openai-capabilities.d.ts +4 -0
  15. package/dist/src/openai-capabilities.js +1569 -0
  16. package/dist/src/openai-conformance.d.ts +13 -0
  17. package/dist/src/openai-conformance.js +116 -0
  18. package/dist/src/openai-connector.d.ts +86 -0
  19. package/dist/src/openai-connector.js +291 -0
  20. package/dist/src/openai-media.d.ts +43 -0
  21. package/dist/src/openai-media.js +257 -0
  22. package/dist/src/openai-models.d.ts +74 -0
  23. package/dist/src/openai-models.js +148 -0
  24. package/dist/src/openai-scenario.d.ts +51 -0
  25. package/dist/src/openai-scenario.js +166 -0
  26. package/dist/src/openai-server.d.ts +40 -0
  27. package/dist/src/openai-server.js +126 -0
  28. package/dist/src/openai-stub.d.ts +82 -0
  29. package/dist/src/openai-stub.js +256 -0
  30. package/dist/src/openai-twin.d.ts +182 -0
  31. package/dist/src/openai-twin.js +1117 -0
  32. package/dist/src/openai-types.d.ts +194 -0
  33. package/dist/src/openai-types.js +4 -0
  34. package/dist/src/openai-webhooks.d.ts +47 -0
  35. package/dist/src/openai-webhooks.js +99 -0
  36. package/dist/src/screens/api-keys.d.ts +16 -0
  37. package/dist/src/screens/api-keys.js +131 -0
  38. package/dist/src/screens/session.d.ts +22 -0
  39. package/dist/src/screens/session.js +115 -0
  40. package/dist/src/semantics/assistants.d.ts +2 -0
  41. package/dist/src/semantics/assistants.js +331 -0
  42. package/dist/src/semantics/audio.d.ts +2 -0
  43. package/dist/src/semantics/audio.js +27 -0
  44. package/dist/src/semantics/batches.d.ts +4 -0
  45. package/dist/src/semantics/batches.js +86 -0
  46. package/dist/src/semantics/chat-completions.d.ts +3 -0
  47. package/dist/src/semantics/chat-completions.js +58 -0
  48. package/dist/src/semantics/containers.d.ts +2 -0
  49. package/dist/src/semantics/containers.js +147 -0
  50. package/dist/src/semantics/embeddings.d.ts +2 -0
  51. package/dist/src/semantics/embeddings.js +13 -0
  52. package/dist/src/semantics/evals.d.ts +2 -0
  53. package/dist/src/semantics/evals.js +173 -0
  54. package/dist/src/semantics/files.d.ts +13 -0
  55. package/dist/src/semantics/files.js +59 -0
  56. package/dist/src/semantics/fine-tuning.d.ts +4 -0
  57. package/dist/src/semantics/fine-tuning.js +178 -0
  58. package/dist/src/semantics/images.d.ts +2 -0
  59. package/dist/src/semantics/images.js +18 -0
  60. package/dist/src/semantics/index.d.ts +8 -0
  61. package/dist/src/semantics/index.js +46 -0
  62. package/dist/src/semantics/models.d.ts +2 -0
  63. package/dist/src/semantics/models.js +34 -0
  64. package/dist/src/semantics/moderations.d.ts +2 -0
  65. package/dist/src/semantics/moderations.js +12 -0
  66. package/dist/src/semantics/organization.d.ts +2 -0
  67. package/dist/src/semantics/organization.js +67 -0
  68. package/dist/src/semantics/progress.d.ts +22 -0
  69. package/dist/src/semantics/progress.js +63 -0
  70. package/dist/src/semantics/responses.d.ts +3 -0
  71. package/dist/src/semantics/responses.js +153 -0
  72. package/dist/src/semantics/shared.d.ts +32 -0
  73. package/dist/src/semantics/shared.js +69 -0
  74. package/dist/src/semantics/uploads.d.ts +2 -0
  75. package/dist/src/semantics/uploads.js +84 -0
  76. package/dist/src/semantics/vector-stores.d.ts +2 -0
  77. package/dist/src/semantics/vector-stores.js +281 -0
  78. package/dist/test-fixtures/openai-openapi-operations.SOURCE.md +18 -0
  79. package/dist/test-fixtures/openai-openapi-operations.json +1849 -0
  80. package/package.json +21 -10
  81. package/src/cli.ts +9 -7
  82. package/src/generated/surface.gen.json +1 -0
  83. package/src/generated/ui.gen.json +1 -0
  84. package/src/index.ts +20 -10
  85. package/src/manifest.ts +343 -0
  86. package/src/openai-budget.ts +4 -4
  87. package/src/openai-capabilities.ts +177 -195
  88. package/src/openai-conformance.ts +1 -1
  89. package/src/openai-connector.ts +40 -43
  90. package/src/openai-media.ts +225 -0
  91. package/src/openai-models.ts +145 -15
  92. package/src/openai-scenario.ts +46 -10
  93. package/src/openai-server.ts +65 -108
  94. package/src/openai-stub.ts +54 -30
  95. package/src/openai-twin.ts +760 -1665
  96. package/src/openai-types.ts +24 -6
  97. package/src/openai-webhooks.ts +3 -2
  98. package/src/screens/api-keys.tsx +138 -0
  99. package/src/screens/session.tsx +131 -0
  100. package/src/semantics/assistants.ts +336 -0
  101. package/src/semantics/audio.ts +31 -0
  102. package/src/semantics/batches.ts +88 -0
  103. package/src/semantics/chat-completions.ts +66 -0
  104. package/src/semantics/containers.ts +151 -0
  105. package/src/semantics/embeddings.ts +19 -0
  106. package/src/semantics/evals.ts +182 -0
  107. package/src/semantics/files.ts +67 -0
  108. package/src/semantics/fine-tuning.ts +185 -0
  109. package/src/semantics/images.ts +23 -0
  110. package/src/semantics/index.ts +52 -0
  111. package/src/semantics/models.ts +41 -0
  112. package/src/semantics/moderations.ts +14 -0
  113. package/src/semantics/organization.ts +76 -0
  114. package/src/semantics/progress.ts +72 -0
  115. package/src/semantics/responses.ts +151 -0
  116. package/src/semantics/shared.ts +82 -0
  117. package/src/semantics/uploads.ts +92 -0
  118. package/src/semantics/vector-stores.ts +279 -0
  119. package/test-fixtures/openai-openapi-operations.SOURCE.md +4 -5
  120. package/test-fixtures/openai-openapi-operations.json +224 -1334
@@ -24,7 +24,8 @@ export type ChatUsage = {
24
24
  prompt_tokens: number;
25
25
  completion_tokens: number;
26
26
  total_tokens: number;
27
- completion_tokens_details?: { accepted_prediction_tokens: number; rejected_prediction_tokens: number };
27
+ prompt_tokens_details?: { cached_tokens: number; audio_tokens: number };
28
+ completion_tokens_details?: { reasoning_tokens: number; audio_tokens: number; accepted_prediction_tokens: number; rejected_prediction_tokens: number };
28
29
  };
29
30
 
30
31
  /** A per-token logprob entry (faithful `choices[].logprobs.content[]` shape). */
@@ -42,7 +43,7 @@ export type ChatAudioOutput = { id: string; data: string; transcript: string; ex
42
43
 
43
44
  export type ChatChoice = {
44
45
  index: number;
45
- message: { role: 'assistant'; content: string | null; tool_calls?: ChatToolCall[]; refusal?: null; audio?: ChatAudioOutput };
46
+ message: { role: 'assistant'; content: string | null; tool_calls?: ChatToolCall[]; refusal?: null; annotations?: unknown[]; audio?: ChatAudioOutput };
46
47
  logprobs: { content: ChatLogprobToken[] } | null;
47
48
  finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
48
49
  };
@@ -56,6 +57,7 @@ export type ChatCompletion = {
56
57
  choices: ChatChoice[];
57
58
  usage: ChatUsage;
58
59
  system_fingerprint: string;
60
+ service_tier?: string;
59
61
  metadata?: Record<string, unknown> | null;
60
62
  };
61
63
 
@@ -66,7 +68,7 @@ export type ResponseMessageItem = {
66
68
  id: string;
67
69
  status: 'completed';
68
70
  role: 'assistant';
69
- content: Array<{ type: 'output_text'; text: string; annotations: unknown[] }>;
71
+ content: Array<{ type: 'output_text'; text: string; annotations: unknown[]; logprobs?: unknown[] }>;
70
72
  };
71
73
  /** A reasoning output item (emitted for reasoning models / when `reasoning.effort` is set). The
72
74
  * twin can't run the model, so the reasoning `summary` is a clearly-labeled stub; the item SHAPE
@@ -76,7 +78,21 @@ export type ResponseReasoningItem = {
76
78
  id: string;
77
79
  summary: Array<{ type: 'summary_text'; text: string }>;
78
80
  };
79
- export type ResponseOutputItem = ResponseMessageItem | ResponseReasoningItem;
81
+ /** A function-call output item: the model asking for a tool, as the Responses API shapes it
82
+ * (`call_id` pairs with the caller's later `function_call_output` input item; `arguments` is JSON text). */
83
+ export type ResponseFunctionCallItem = {
84
+ type: 'function_call';
85
+ id: string;
86
+ status: 'completed';
87
+ call_id: string;
88
+ name: string;
89
+ arguments: string;
90
+ };
91
+ /** A built-in tool OpenAI ran for the response: a web search or a file search (the spec's WebSearchToolCall, FileSearchToolCall). */
92
+ export type ResponseBuiltInCallItem =
93
+ | { type: 'web_search_call'; id: string; status: 'completed'; action: { type: 'search'; query: string } }
94
+ | { type: 'file_search_call'; id: string; status: 'completed'; queries: string[]; results: null };
95
+ export type ResponseOutputItem = ResponseMessageItem | ResponseReasoningItem | ResponseFunctionCallItem | ResponseBuiltInCallItem;
80
96
 
81
97
  /** The Responses API usage object — `output_tokens_details.reasoning_tokens` reports tokens spent
82
98
  * on the (stubbed) reasoning item, faithful to the vendor shape. */
@@ -84,6 +100,7 @@ export type ResponseUsage = {
84
100
  input_tokens: number;
85
101
  output_tokens: number;
86
102
  total_tokens: number;
103
+ input_tokens_details?: { cached_tokens: number; cache_write_tokens: number };
87
104
  output_tokens_details?: { reasoning_tokens: number };
88
105
  };
89
106
 
@@ -97,8 +114,9 @@ export type OpenAIResponse = {
97
114
  output: ResponseOutputItem[];
98
115
  output_text?: string;
99
116
  usage: ResponseUsage;
100
- reasoning?: { effort: string; summary: string | null };
101
- previous_response_id?: string;
117
+ completed_at?: number | null;
118
+ reasoning?: { effort: string | null; summary: string | null };
119
+ previous_response_id?: string | null;
102
120
  };
103
121
 
104
122
  // ── Embeddings ──────────────────────────────────────────────────────────────────────────
@@ -1,3 +1,4 @@
1
+ import { nodeBuiltin, worldNow } from '@volter/world-core';
1
2
  // OpenAI webhooks — REAL HMAC-signed delivery, Standard-Webhooks scheme.
2
3
  //
3
4
  // Real OpenAI delivers asynchronous job-completion notifications (batch / fine-tuning /
@@ -13,7 +14,7 @@
13
14
  type NodeCrypto = typeof import('node:crypto');
14
15
  function nodeCrypto(): NodeCrypto {
15
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
- return require('node:crypto') as NodeCrypto;
17
+ return nodeBuiltin('node:crypto') as NodeCrypto;
17
18
  }
18
19
 
19
20
  /** The thin webhook envelope OpenAI POSTs: { object:'event', id, type, created_at, data:{id} }. */
@@ -63,7 +64,7 @@ export function verifyOpenAIWebhook(payload: string, headers: Record<string, str
63
64
  const sigHeader = lower['webhook-signature'];
64
65
  if (!id || !sigHeader || !Number.isFinite(ts)) throw new OpenAIWebhookVerificationError('Missing required webhook headers (webhook-id / webhook-timestamp / webhook-signature).');
65
66
  if (opts.tolerance !== undefined) {
66
- const now = opts.now ?? Math.floor(Date.now() / 1000);
67
+ const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
67
68
  if (Math.abs(now - ts) > opts.tolerance) throw new OpenAIWebhookVerificationError('Webhook timestamp outside tolerance.');
68
69
  }
69
70
  const expected = computeOpenAIWebhookSignature(id, ts, payload, secret).slice('v1,'.length);
@@ -0,0 +1,138 @@
1
+ // OPENAI'S API KEYS PAGE — a workspace screen (docs/contributing/architecture.md, "Screens"): a project's secret keys
2
+ // are made on platform.openai.com, never through the API (docs: platform.openai.com/docs/api-reference/project-api-keys
3
+ // lists, retrieves and deletes them; creating one is the dashboard's "Create new secret key"). The page lists the
4
+ // project's keys, makes a new one from its name and permissions and shows its secret once, and revokes a key. The
5
+ // Admin API then reads and deletes what the page made. Authored from @volter/world-ui's portal piece under OpenAI's
6
+ // skin; nothing of OpenAI's page is copied.
7
+ //
8
+ // The page acts for the person signed in to the dashboard (./session.tsx), never on an API key: a key is for the API,
9
+ // and a request whose session names nobody is sent to log in. A key the page makes is that person's: its owner is
10
+ // their user (https://platform.openai.com/docs/api-reference/project-api-keys/object, owner.user: id, email, name,
11
+ // created_at, role).
12
+ //
13
+ // Where the documentation stops and the twin decides: a key's secret is derived from its id, and shown once, as the
14
+ // dashboard does; any person of the organization may make and revoke a project's keys.
15
+ import { createHash } from 'node:crypto';
16
+ import { semanticsContext, vendorError, type SemanticsContext } from '@volter/world-core';
17
+ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
18
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
19
+ import { manifest } from '../manifest.ts';
20
+ import { epoch } from '../semantics/shared.ts';
21
+ import { signedIn, toLogIn, type Account } from './session.tsx';
22
+
23
+ const KEY = 'ProjectApiKey';
24
+ const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'list-project-api-keys')!;
25
+ // a key's owner as the spec's ProjectApiKeyOwnerUser gives it: the person's user, created_at being when they joined, and
26
+ // role their role in the project, "owner" or "member" (https://platform.openai.com/docs/api-reference/project-users/object).
27
+ // The twin keeps no project members: the organization's owner owns every project, and anyone else who makes a key there
28
+ // is a member of it.
29
+ const ownerOf = (person: Account): Record<string, unknown> => ({ id: person.id, email: person.email, name: person.name, created_at: person.added_at, role: person.role === 'owner' ? 'owner' : 'member' });
30
+ const PERMISSIONS = [{ value: 'all', label: 'All' }, { value: 'restricted', label: 'Restricted' }, { value: 'read_only', label: 'Read only' }];
31
+
32
+ const OPENAI_SKIN = `
33
+ body { background: #ffffff; color: #0d0d0d; font-family: "Söhne", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
34
+ .portal-side { background: #f9f9f9; border-right: 1px solid #ececec; }
35
+ .portal-section h2 { border-color: #ececec; color: #5d5d5d; }
36
+ .portal-notice { background: #f0fdf4; border: 1px solid #bbf7d0; }
37
+ .portal-detail, .portal-note { color: #5d5d5d; }
38
+ .portal-button { border-color: #d9d9d9; color: #0d0d0d; }
39
+ .portal-primary { background: #0d0d0d; border-color: #0d0d0d; color: #ffffff; }
40
+ .portal-danger { color: #d00e17; border-color: #f3b4b7; }
41
+ .portal-field input, .portal-field select { border-color: #d9d9d9; }
42
+ `;
43
+ const css = [PORTAL_CSS, OPENAI_SKIN];
44
+
45
+ const secretOf = (id: string): string => `sk-proj-${createHash('sha256').update(`project-key:${id}`).digest('hex').slice(0, 48)}`;
46
+ const redacted = (secret: string): string => `${secret.slice(0, 11)}...${secret.slice(-4)}`;
47
+
48
+ /** The fields a submission carries: the page's form, as a browser posts it. */
49
+ async function formOf(request: Request): Promise<Record<string, string>> {
50
+ return Object.fromEntries(new URLSearchParams(await request.text()));
51
+ }
52
+
53
+ function page(ctx: SemanticsContext, project: Record<string, unknown>, notice?: string, status = 200): Response {
54
+ const id = String(project.id);
55
+ const keys = ctx.rowsRaw(KEY).filter((k) => k._project_id === id).sort((a, b) => Number(b.created_at) - Number(a.created_at));
56
+ const action = `/settings/${id}/api-keys`;
57
+ return flowPage({
58
+ title: 'API keys - OpenAI API',
59
+ status,
60
+ css,
61
+ body: (
62
+ <Portal
63
+ merchant="OpenAI Platform"
64
+ back={{ href: '/settings/organization/general', label: String(project.name) }}
65
+ {...(notice ? { notice } : {})}
66
+ sections={[{
67
+ heading: 'API keys',
68
+ empty: 'This project has no API keys yet.',
69
+ items: keys.map((k) => ({
70
+ title: String(k.name),
71
+ detail: String(k.redacted_value),
72
+ note: `Created ${new Date(Number(k.created_at) * 1000).toISOString().slice(0, 10)} by ${String((k.owner as { user?: { name?: string } } | undefined)?.user?.name ?? '')}`,
73
+ actions: [{ label: 'Revoke key', action, fields: { revoke: String(k.id) }, tone: 'danger' as const }],
74
+ })),
75
+ }]}
76
+ forms={[{
77
+ heading: 'Create new secret key',
78
+ action,
79
+ fields: [
80
+ { id: 'name', label: 'Name', placeholder: 'My Test Key' },
81
+ { id: 'permissions', label: 'Permissions', options: PERMISSIONS, value: 'all' },
82
+ ],
83
+ submit: { label: 'Create secret key' },
84
+ }]}
85
+ />
86
+ ),
87
+ });
88
+ }
89
+
90
+ /** A request that presents one of the organization's project keys uses it: the key's `last_used_at` is that instant
91
+ * (https://platform.openai.com/docs/api-reference/project-api-keys/object, "The Unix timestamp (in seconds) of when the
92
+ * API key was last used."). A key that has stopped working is refused: one revoked on the keys page or deleted through the
93
+ * Admin API, and one of an archived project ("Archived projects cannot be used or updated",
94
+ * https://platform.openai.com/docs/api-reference/projects/archive). The refusal is OpenAI's incorrect-key answer (the
95
+ * manifest's `auth.invalid`), as OpenAI documents no other for them; the answer is that refusal, or nothing to let the
96
+ * request on. */
97
+ export function projectKeyUse(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
98
+ return async (request) => {
99
+ const bearer = /^Bearer\s+(sk-proj-\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
100
+ if (!bearer) return undefined;
101
+ const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), OPERATION, scope);
102
+ const key = ctx.rowsRaw(KEY, { withDeleted: true }).find((k) => secretOf(String(k.id)) === bearer);
103
+ const archived = key ? ctx.row('Project', String(key._project_id))?.status === 'archived' : false;
104
+ if (key?.deleted === true || archived) return vendorError(manifest, manifest.auth!.invalid);
105
+ if (key) await ctx.write(KEY, String(key.id), { last_used_at: epoch(ctx) }, 'api_key.update');
106
+ return undefined;
107
+ };
108
+ }
109
+
110
+ /** platform.openai.com/settings/{project}/api-keys, or undefined for any other request. */
111
+
112
+ export function openaiApiKeysScreen(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
113
+ return async (request) => {
114
+ const m = /^\/settings\/(proj[-_][A-Za-z0-9_-]+)\/api-keys\/?$/.exec(new URL(request.url).pathname);
115
+ if (!m || (request.method !== 'GET' && request.method !== 'POST')) return undefined;
116
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
117
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
118
+ const person = signedIn(request, ctx);
119
+ if (!person) return toLogIn(request);
120
+ const project = ctx.get('Project', m[1]!);
121
+ if (!project) return flowPage({ title: 'OpenAI Platform', status: 404, css, body: <main className="portal-main"><h1>Project not found</h1></main> });
122
+ if (request.method === 'GET') return page(ctx, project);
123
+ if (form.revoke) {
124
+ const key = ctx.row(KEY, form.revoke);
125
+ if (!key || key._project_id !== project.id) return page(ctx, project, 'That key no longer exists.', 404);
126
+ await ctx.write(KEY, form.revoke, { deleted: true }, 'api_key.delete');
127
+ return page(ctx, project, `Revoked ${String(key.name)}. Requests using it will now fail.`);
128
+ }
129
+ const name = (form.name ?? '').trim() || 'Secret key';
130
+ const id = ctx.mint(KEY);
131
+ const secret = secretOf(id);
132
+ await ctx.write(KEY, id, {
133
+ object: 'organization.project.api_key', name, redacted_value: redacted(secret), created_at: epoch(ctx), last_used_at: null,
134
+ _project_id: project.id, owner: { type: 'user', user: ownerOf(person) }, owner_project_access: 'active', _permissions: PERMISSIONS.some((p) => p.value === form.permissions) ? form.permissions : 'all',
135
+ }, 'api_key.create');
136
+ return page(ctx, project, `Save your key: ${secret}. You won't be able to view it again.`, 201);
137
+ };
138
+ }
@@ -0,0 +1,131 @@
1
+ // OPENAI'S LOG-IN — the door before the dashboard's pages (docs/contributing/architecture.md, "Who is on a screen"): the
2
+ // log-in page takes a person's email address and password, the right pair signs them in, and the dashboard's session
3
+ // cookie names them to every page after. A dashboard page asked for by nobody signed in sends its visitor to the log-in
4
+ // with `return_to`, and the log-in returns them there. Built from @volter/world-ui's sign-in piece under OpenAI's skin;
5
+ // nothing of OpenAI's page is copied.
6
+ //
7
+ // A person is an account of the World's organization, as the Admin API's user object gives one
8
+ // (https://platform.openai.com/docs/api-reference/users/object: id, name, email, role "owner" or "reader", added_at).
9
+ //
10
+ // Where OpenAI's documentation stops and the twin decides: an account is made at the World's door
11
+ // (`POST /_twin/accounts`, standing in for OpenAI's sign-up, which is not modelled), and the World keeps its password's
12
+ // hash, never the password; the World has one organization, whose first account is its owner and whose later ones join
13
+ // as readers unless the door names a role; the log-in is one page (OpenAI's asks for the email and the password on two),
14
+ // served on the dashboard's own host at platform.openai.com/login, the address OpenAI's log-in link opens (OpenAI hands it
15
+ // on to auth.openai.com, which the twin does not model as a host of its own), so its session cookie is the dashboard's;
16
+ // the session cookie's name (`platform_session`) is the twin's, as OpenAI documents none; a refused log-in shows the page
17
+ // again with the message, answered 200; and a session does not end: no person of a World logs out yet.
18
+ import { createHash } from 'node:crypto';
19
+ import { semanticsContext, type SemanticsContext } from '@volter/world-core';
20
+ import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
21
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
22
+ import { manifest } from '../manifest.ts';
23
+ import { epoch } from '../semantics/shared.ts';
24
+
25
+ type Scope = { root?: string; clock?: () => string };
26
+ /** A person's account: the user object's fields, and the hash of the password the World gave them. */
27
+ export type Account = { id: string; object: 'organization.user'; name: string; email: string; role: 'owner' | 'reader'; added_at: number; _hash: string };
28
+
29
+ const ACCOUNT = '_account';
30
+ const SESSION = '_session';
31
+ const COOKIE = 'platform_session';
32
+ const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'retrieve-user')!;
33
+ const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
34
+ const passwordHash = (email: string, password: string): string => hex(`password:${email.toLowerCase()}:${password}`);
35
+
36
+ // OpenAI's skin over the sign-in: its black primary button on white
37
+ const SKIN = `
38
+ body { background: #ffffff; color: #0d0d0d; font-family: "Söhne", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
39
+ .sign-in-mark { background: #0d0d0d; }
40
+ .sign-in-error { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
41
+ .sign-in-box { background: #ffffff; border-color: #ececec; }
42
+ .sign-in-box input[type=text], .sign-in-box input[type=password] { border-color: #d9d9d9; }
43
+ .sign-in-submit { background: #0d0d0d; border-color: #0d0d0d; color: #ffffff; }
44
+ `;
45
+
46
+ const contextOf = (request: Request, scope: Scope): Promise<SemanticsContext> => semanticsContext(manifest, request, OPERATION, scope);
47
+ const accounts = (ctx: SemanticsContext): Account[] => ctx.rowsRaw(ACCOUNT) as unknown as Account[];
48
+
49
+ /** The account the request's session cookie names, or undefined when nobody is signed in. */
50
+ export function signedIn(request: Request, ctx: SemanticsContext): Account | undefined {
51
+ const token = cookieOf(request, COOKIE);
52
+ const session = token ? ctx.rowsRaw(SESSION).find((s) => s.token === token) : undefined;
53
+ return session ? accounts(ctx).find((a) => a.id === session.account) : undefined;
54
+ }
55
+
56
+ /** Where a dashboard page asked for by nobody signed in sends its visitor: the log-in, returning here after. */
57
+ export function toLogIn(request: Request): Response {
58
+ const url = new URL(request.url);
59
+ return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
60
+ }
61
+
62
+ /** A return_to the log-in follows: a path on the dashboard, never another site. */
63
+ const returnTo = (raw: string | null | undefined): string => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
64
+
65
+ function page(back: string, email?: string, error?: string): Response {
66
+ return flowPage({
67
+ title: 'Log in - OpenAI',
68
+ css: [SIGN_IN_CSS, SKIN],
69
+ body: (
70
+ <SignIn
71
+ heading="Welcome back"
72
+ action="/login"
73
+ fields={{ return_to: back }}
74
+ account={{ name: 'email', label: 'Email address', ...(email ? { value: email } : {}) }}
75
+ password={{ name: 'password', label: 'Password' }}
76
+ submit="Continue"
77
+ {...(error ? { error } : {})}
78
+ />
79
+ ),
80
+ });
81
+ }
82
+
83
+ async function logIn(request: Request, scope: Scope): Promise<Response> {
84
+ const form = Object.fromEntries(new URLSearchParams(await request.clone().text()));
85
+ const email = (form.email ?? '').trim();
86
+ const back = returnTo(form.return_to);
87
+ const ctx = await contextOf(request, scope);
88
+ const account = accounts(ctx).find((a) => a.email.toLowerCase() === email.toLowerCase());
89
+ // the log-in names neither which half was wrong
90
+ if (!account || account._hash !== passwordHash(account.email, form.password ?? '')) return page(back, email, 'Wrong email or password.');
91
+ const n = ctx.rowsRaw(SESSION).length + 1;
92
+ const token = hex(`session:${account.id}:${ctx.occurredAt}:${n}`).slice(0, 48);
93
+ await ctx.record(SESSION, { token, account: account.id, created_at: epoch(ctx) }, `session_${token.slice(0, 16)}`);
94
+ return new Response(null, { status: 302, headers: { location: back, 'set-cookie': `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax` } });
95
+ }
96
+
97
+ /** The World's account door refusing an account it cannot make: one already made for the email, or one missing a field. */
98
+ function badAccount(email: string, taken: boolean): Response {
99
+ return Response.json({ error: { message: taken ? `An account for ${email} already exists.` : 'An account needs an email address, a name and a password; a role is "owner" or "reader".', type: 'invalid_request_error' } }, { status: 400 });
100
+ }
101
+
102
+ /** POST /_twin/accounts {email, name, password, role?} → 201 the account's user object: a person of the World's
103
+ * organization, who logs in with that password. */
104
+ async function makeAccount(request: Request, scope: Scope): Promise<Response> {
105
+ let body: Record<string, unknown> = {};
106
+ try { body = await request.clone().json() as Record<string, unknown>; } catch { /* answered below */ }
107
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
108
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
109
+ const password = typeof body.password === 'string' ? body.password : '';
110
+ const ctx = await contextOf(request, scope);
111
+ const taken = accounts(ctx).some((a) => a.email.toLowerCase() === email.toLowerCase());
112
+ if (!/^[^@\s]+@[^@\s]+$/.test(email) || !name || !password || taken || (body.role !== undefined && body.role !== 'owner' && body.role !== 'reader')) return badAccount(email, taken);
113
+ const role = (body.role as Account['role'] | undefined) ?? (accounts(ctx).some((a) => a.role === 'owner') ? 'reader' : 'owner');
114
+ const id = `user-${hex(`account:${email.toLowerCase()}`).slice(0, 24)}`;
115
+ const account: Account = { id, object: 'organization.user', name, email, role, added_at: epoch(ctx), _hash: passwordHash(email, password) };
116
+ await ctx.record(ACCOUNT, account, id);
117
+ const { _hash: _secret, ...user } = account;
118
+ return Response.json(user, { status: 201 });
119
+ }
120
+
121
+ /** The log-in's paths and the World's account door, or undefined for any other request. */
122
+ export function openaiSessionFlow(scope: Scope): (request: Request) => Promise<Response | undefined> {
123
+ return async (request) => {
124
+ const url = new URL(request.url);
125
+ const path = url.pathname.replace(/\/+$/, '');
126
+ if (path === '/login' && request.method === 'GET') return page(returnTo(url.searchParams.get('return_to')));
127
+ if (path === '/login' && request.method === 'POST') return logIn(request, scope);
128
+ if (path === '/_twin/accounts' && request.method === 'POST') return makeAccount(request, scope);
129
+ return undefined;
130
+ };
131
+ }