@volter/twin-openai 0.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +2 -1
  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
@@ -0,0 +1,194 @@
1
+ /** A chat message param as the caller sends it (content is a string OR a content-part array). */
2
+ export type ChatMessageParam = {
3
+ role: 'system' | 'user' | 'assistant' | 'tool' | 'developer';
4
+ content?: string | Array<Record<string, unknown>> | null;
5
+ name?: string;
6
+ tool_calls?: ChatToolCall[];
7
+ tool_call_id?: string;
8
+ };
9
+ /** A function tool_call inside an assistant message (faithful shape). */
10
+ export type ChatToolCall = {
11
+ id: string;
12
+ type: 'function';
13
+ function: {
14
+ name: string;
15
+ arguments: string;
16
+ };
17
+ };
18
+ /** The chat.completion `usage` object — deterministic token counts. */
19
+ export type ChatUsage = {
20
+ prompt_tokens: number;
21
+ completion_tokens: number;
22
+ total_tokens: number;
23
+ prompt_tokens_details?: {
24
+ cached_tokens: number;
25
+ audio_tokens: number;
26
+ };
27
+ completion_tokens_details?: {
28
+ reasoning_tokens: number;
29
+ audio_tokens: number;
30
+ accepted_prediction_tokens: number;
31
+ rejected_prediction_tokens: number;
32
+ };
33
+ };
34
+ /** A per-token logprob entry (faithful `choices[].logprobs.content[]` shape). */
35
+ export type ChatLogprobToken = {
36
+ token: string;
37
+ logprob: number;
38
+ bytes: number[];
39
+ top_logprobs: Array<{
40
+ token: string;
41
+ logprob: number;
42
+ bytes: number[];
43
+ }>;
44
+ };
45
+ /** An audio output object on an assistant message (when `modalities` includes 'audio'). The twin
46
+ * can't synthesize speech, so `data` is a clearly-labeled stub base64 string; the SHAPE
47
+ * (id/data/transcript/expires_at) is vendor-faithful. */
48
+ export type ChatAudioOutput = {
49
+ id: string;
50
+ data: string;
51
+ transcript: string;
52
+ expires_at: number;
53
+ };
54
+ export type ChatChoice = {
55
+ index: number;
56
+ message: {
57
+ role: 'assistant';
58
+ content: string | null;
59
+ tool_calls?: ChatToolCall[];
60
+ refusal?: null;
61
+ annotations?: unknown[];
62
+ audio?: ChatAudioOutput;
63
+ };
64
+ logprobs: {
65
+ content: ChatLogprobToken[];
66
+ } | null;
67
+ finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
68
+ };
69
+ /** The unary chat.completion response envelope (faithful shape). */
70
+ export type ChatCompletion = {
71
+ id: string;
72
+ object: 'chat.completion';
73
+ created: number;
74
+ model: string;
75
+ choices: ChatChoice[];
76
+ usage: ChatUsage;
77
+ system_fingerprint: string;
78
+ service_tier?: string;
79
+ metadata?: Record<string, unknown> | null;
80
+ };
81
+ /** A message output item (the assistant's text turn). */
82
+ export type ResponseMessageItem = {
83
+ type: 'message';
84
+ id: string;
85
+ status: 'completed';
86
+ role: 'assistant';
87
+ content: Array<{
88
+ type: 'output_text';
89
+ text: string;
90
+ annotations: unknown[];
91
+ logprobs?: unknown[];
92
+ }>;
93
+ };
94
+ /** A reasoning output item (emitted for reasoning models / when `reasoning.effort` is set). The
95
+ * twin can't run the model, so the reasoning `summary` is a clearly-labeled stub; the item SHAPE
96
+ * (type/id/summary[]) is vendor-faithful. */
97
+ export type ResponseReasoningItem = {
98
+ type: 'reasoning';
99
+ id: string;
100
+ summary: Array<{
101
+ type: 'summary_text';
102
+ text: string;
103
+ }>;
104
+ };
105
+ /** A function-call output item: the model asking for a tool, as the Responses API shapes it
106
+ * (`call_id` pairs with the caller's later `function_call_output` input item; `arguments` is JSON text). */
107
+ export type ResponseFunctionCallItem = {
108
+ type: 'function_call';
109
+ id: string;
110
+ status: 'completed';
111
+ call_id: string;
112
+ name: string;
113
+ arguments: string;
114
+ };
115
+ /** A built-in tool OpenAI ran for the response: a web search or a file search (the spec's WebSearchToolCall, FileSearchToolCall). */
116
+ export type ResponseBuiltInCallItem = {
117
+ type: 'web_search_call';
118
+ id: string;
119
+ status: 'completed';
120
+ action: {
121
+ type: 'search';
122
+ query: string;
123
+ };
124
+ } | {
125
+ type: 'file_search_call';
126
+ id: string;
127
+ status: 'completed';
128
+ queries: string[];
129
+ results: null;
130
+ };
131
+ export type ResponseOutputItem = ResponseMessageItem | ResponseReasoningItem | ResponseFunctionCallItem | ResponseBuiltInCallItem;
132
+ /** The Responses API usage object — `output_tokens_details.reasoning_tokens` reports tokens spent
133
+ * on the (stubbed) reasoning item, faithful to the vendor shape. */
134
+ export type ResponseUsage = {
135
+ input_tokens: number;
136
+ output_tokens: number;
137
+ total_tokens: number;
138
+ input_tokens_details?: {
139
+ cached_tokens: number;
140
+ cache_write_tokens: number;
141
+ };
142
+ output_tokens_details?: {
143
+ reasoning_tokens: number;
144
+ };
145
+ };
146
+ /** The Responses API response envelope (faithful shape). */
147
+ export type OpenAIResponse = {
148
+ id: string;
149
+ object: 'response';
150
+ created_at: number;
151
+ status: 'completed';
152
+ model: string;
153
+ output: ResponseOutputItem[];
154
+ output_text?: string;
155
+ usage: ResponseUsage;
156
+ completed_at?: number | null;
157
+ reasoning?: {
158
+ effort: string | null;
159
+ summary: string | null;
160
+ };
161
+ previous_response_id?: string | null;
162
+ };
163
+ export type Embedding = {
164
+ object: 'embedding';
165
+ index: number;
166
+ embedding: number[];
167
+ };
168
+ export type EmbeddingResponse = {
169
+ object: 'list';
170
+ data: Embedding[];
171
+ model: string;
172
+ usage: {
173
+ prompt_tokens: number;
174
+ total_tokens: number;
175
+ };
176
+ };
177
+ /** A vendor-shaped error envelope: { error: { message, type, param, code } }. */
178
+ export type OpenAIError = {
179
+ error: {
180
+ message: string;
181
+ type: string;
182
+ param: string | null;
183
+ code: string | null;
184
+ };
185
+ };
186
+ /** A single Server-Sent Event the streaming path emits (collected, never socketed in tests).
187
+ * `data` is the JSON payload; `[DONE]` is signalled with `done: true` (no data object). */
188
+ export type SseEvent = {
189
+ data?: Record<string, unknown>;
190
+ done?: boolean;
191
+ };
192
+ /** A sink the streaming path writes events into (an injected collector in tests / a real
193
+ * HTTP SSE writer in the server). NO real sockets or setTimeout in the handler. */
194
+ export type SseSink = (event: SseEvent) => void;
@@ -0,0 +1,4 @@
1
+ // Shared wire-shape types for the OpenAI API surface. These mirror the real vendor JSON
2
+ // shapes (not the SDK's internal types — the twin never imports the SDK at runtime; the SDK
3
+ // is exercised only in *.test.ts). Kept minimal but faithful.
4
+ export {};
@@ -0,0 +1,47 @@
1
+ /** The thin webhook envelope OpenAI POSTs: { object:'event', id, type, created_at, data:{id} }. */
2
+ export type OpenAIWebhookEvent = {
3
+ object: 'event';
4
+ id: string;
5
+ type: string;
6
+ created_at: number;
7
+ data: {
8
+ id: string;
9
+ [k: string]: unknown;
10
+ };
11
+ };
12
+ /**
13
+ * Compute the `webhook-signature` header value for a delivery: `v1,<base64 sig>` over
14
+ * `${id}.${timestamp}.${payload}` keyed by the (base64-decoded) signing secret — the Standard
15
+ * Webhooks scheme. A real Standard-Webhooks / svix verifier accepts this unchanged.
16
+ */
17
+ export declare function computeOpenAIWebhookSignature(id: string, timestamp: number, payload: string, secret: string): string;
18
+ export declare class OpenAIWebhookVerificationError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ /**
22
+ * Verify a webhook payload + Standard-Webhooks headers against the signing secret and return the
23
+ * parsed event (mirrors the SDK's `webhooks.unwrap`). Throws on a missing/malformed header, a
24
+ * signature mismatch, or (when `tolerance` is given) a stale timestamp.
25
+ */
26
+ export declare function verifyOpenAIWebhook(payload: string, headers: Record<string, string>, secret: string, opts?: {
27
+ tolerance?: number;
28
+ now?: number;
29
+ }): OpenAIWebhookEvent;
30
+ /** The webhook event types OpenAI emits (a faithful slice of the published set). */
31
+ export declare const OPENAI_WEBHOOK_EVENT_TYPES: readonly ["response.completed", "response.cancelled", "response.failed", "response.incomplete", "batch.completed", "batch.cancelled", "batch.expired", "batch.failed", "fine_tuning.job.succeeded", "fine_tuning.job.cancelled", "fine_tuning.job.failed", "eval.run.succeeded", "eval.run.canceled", "eval.run.failed"];
32
+ /**
33
+ * Build a SIGNED webhook delivery for an async job completion: the thin event envelope + the
34
+ * Standard-Webhooks headers carrying a REAL HMAC signature. Deterministic + offline.
35
+ */
36
+ export declare function buildSignedOpenAIWebhook(args: {
37
+ type: string;
38
+ resourceId: string;
39
+ secret: string;
40
+ occurredAt: string;
41
+ webhookId: string;
42
+ extra?: Record<string, unknown>;
43
+ }): {
44
+ headers: Record<string, string>;
45
+ body: string;
46
+ event: OpenAIWebhookEvent;
47
+ };
@@ -0,0 +1,99 @@
1
+ import { nodeBuiltin } from '@volter/world-core';
2
+ function nodeCrypto() {
3
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4
+ return nodeBuiltin('node:crypto');
5
+ }
6
+ /** Decode a `whsec_<base64>` signing secret to its raw HMAC key bytes. */
7
+ function secretKeyBytes(secret) {
8
+ const b64 = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
9
+ return Buffer.from(b64, 'base64');
10
+ }
11
+ /**
12
+ * Compute the `webhook-signature` header value for a delivery: `v1,<base64 sig>` over
13
+ * `${id}.${timestamp}.${payload}` keyed by the (base64-decoded) signing secret — the Standard
14
+ * Webhooks scheme. A real Standard-Webhooks / svix verifier accepts this unchanged.
15
+ */
16
+ export function computeOpenAIWebhookSignature(id, timestamp, payload, secret) {
17
+ const key = secretKeyBytes(secret);
18
+ const signed = `${id}.${timestamp}.${payload}`;
19
+ const sig = nodeCrypto().createHmac('sha256', key).update(signed, 'utf8').digest('base64');
20
+ return `v1,${sig}`;
21
+ }
22
+ export class OpenAIWebhookVerificationError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'OpenAIWebhookVerificationError';
26
+ }
27
+ }
28
+ /**
29
+ * Verify a webhook payload + Standard-Webhooks headers against the signing secret and return the
30
+ * parsed event (mirrors the SDK's `webhooks.unwrap`). Throws on a missing/malformed header, a
31
+ * signature mismatch, or (when `tolerance` is given) a stale timestamp.
32
+ */
33
+ export function verifyOpenAIWebhook(payload, headers, secret, opts = {}) {
34
+ const lower = {};
35
+ for (const [k, v] of Object.entries(headers))
36
+ lower[k.toLowerCase()] = v;
37
+ const id = lower['webhook-id'];
38
+ const ts = Number(lower['webhook-timestamp']);
39
+ const sigHeader = lower['webhook-signature'];
40
+ if (!id || !sigHeader || !Number.isFinite(ts))
41
+ throw new OpenAIWebhookVerificationError('Missing required webhook headers (webhook-id / webhook-timestamp / webhook-signature).');
42
+ if (opts.tolerance !== undefined) {
43
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
44
+ if (Math.abs(now - ts) > opts.tolerance)
45
+ throw new OpenAIWebhookVerificationError('Webhook timestamp outside tolerance.');
46
+ }
47
+ const expected = computeOpenAIWebhookSignature(id, ts, payload, secret).slice('v1,'.length);
48
+ const expectedBuf = Buffer.from(expected, 'utf8');
49
+ // The header may carry multiple space-separated `v<n>,<sig>` pairs.
50
+ const matched = sigHeader.split(' ').some((part) => {
51
+ const comma = part.indexOf(',');
52
+ if (comma === -1)
53
+ return false;
54
+ const sig = part.slice(comma + 1);
55
+ const sigBuf = Buffer.from(sig, 'utf8');
56
+ return sigBuf.length === expectedBuf.length && nodeCrypto().timingSafeEqual(sigBuf, expectedBuf);
57
+ });
58
+ if (!matched)
59
+ throw new OpenAIWebhookVerificationError('No matching signature found.');
60
+ return JSON.parse(payload);
61
+ }
62
+ /** The webhook event types OpenAI emits (a faithful slice of the published set). */
63
+ export const OPENAI_WEBHOOK_EVENT_TYPES = [
64
+ 'response.completed',
65
+ 'response.cancelled',
66
+ 'response.failed',
67
+ 'response.incomplete',
68
+ 'batch.completed',
69
+ 'batch.cancelled',
70
+ 'batch.expired',
71
+ 'batch.failed',
72
+ 'fine_tuning.job.succeeded',
73
+ 'fine_tuning.job.cancelled',
74
+ 'fine_tuning.job.failed',
75
+ 'eval.run.succeeded',
76
+ 'eval.run.canceled',
77
+ 'eval.run.failed',
78
+ ];
79
+ /**
80
+ * Build a SIGNED webhook delivery for an async job completion: the thin event envelope + the
81
+ * Standard-Webhooks headers carrying a REAL HMAC signature. Deterministic + offline.
82
+ */
83
+ export function buildSignedOpenAIWebhook(args) {
84
+ const ts = Math.floor(Date.parse(args.occurredAt) / 1000);
85
+ const event = {
86
+ object: 'event',
87
+ id: args.webhookId,
88
+ type: args.type,
89
+ created_at: ts,
90
+ data: { id: args.resourceId, ...(args.extra ?? {}) },
91
+ };
92
+ const body = JSON.stringify(event);
93
+ const headers = {
94
+ 'webhook-id': args.webhookId,
95
+ 'webhook-timestamp': String(ts),
96
+ 'webhook-signature': computeOpenAIWebhookSignature(args.webhookId, ts, body, args.secret),
97
+ };
98
+ return { headers, body, event };
99
+ }
@@ -0,0 +1,16 @@
1
+ /** A request that presents one of the organization's project keys uses it: the key's `last_used_at` is that instant
2
+ * (https://platform.openai.com/docs/api-reference/project-api-keys/object, "The Unix timestamp (in seconds) of when the
3
+ * API key was last used."). A key that has stopped working is refused: one revoked on the keys page or deleted through the
4
+ * Admin API, and one of an archived project ("Archived projects cannot be used or updated",
5
+ * https://platform.openai.com/docs/api-reference/projects/archive). The refusal is OpenAI's incorrect-key answer (the
6
+ * manifest's `auth.invalid`), as OpenAI documents no other for them; the answer is that refusal, or nothing to let the
7
+ * request on. */
8
+ export declare function projectKeyUse(scope: {
9
+ root?: string;
10
+ clock?: () => string;
11
+ }): (request: Request) => Promise<Response | undefined>;
12
+ /** platform.openai.com/settings/{project}/api-keys, or undefined for any other request. */
13
+ export declare function openaiApiKeysScreen(scope: {
14
+ root?: string;
15
+ clock?: () => string;
16
+ }): (request: Request) => Promise<Response | undefined>;
@@ -0,0 +1,131 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // OPENAI'S API KEYS PAGE — a workspace screen (docs/contributing/architecture.md, "Screens"): a project's secret keys
3
+ // are made on platform.openai.com, never through the API (docs: platform.openai.com/docs/api-reference/project-api-keys
4
+ // lists, retrieves and deletes them; creating one is the dashboard's "Create new secret key"). The page lists the
5
+ // project's keys, makes a new one from its name and permissions and shows its secret once, and revokes a key. The
6
+ // Admin API then reads and deletes what the page made. Authored from @volter/world-ui's portal piece under OpenAI's
7
+ // skin; nothing of OpenAI's page is copied.
8
+ //
9
+ // The page acts for the person signed in to the dashboard (./session.tsx), never on an API key: a key is for the API,
10
+ // and a request whose session names nobody is sent to log in. A key the page makes is that person's: its owner is
11
+ // their user (https://platform.openai.com/docs/api-reference/project-api-keys/object, owner.user: id, email, name,
12
+ // created_at, role).
13
+ //
14
+ // Where the documentation stops and the twin decides: a key's secret is derived from its id, and shown once, as the
15
+ // dashboard does; any person of the organization may make and revoke a project's keys.
16
+ import { createHash } from 'node:crypto';
17
+ import { semanticsContext, vendorError } from '@volter/world-core';
18
+ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
19
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
20
+ import { manifest } from "../manifest.js";
21
+ import { epoch } from "../semantics/shared.js";
22
+ import { signedIn, toLogIn } from "./session.js";
23
+ const KEY = 'ProjectApiKey';
24
+ const OPERATION = surface.operations.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) => ({ 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
+ const OPENAI_SKIN = `
32
+ body { background: #ffffff; color: #0d0d0d; font-family: "Söhne", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
33
+ .portal-side { background: #f9f9f9; border-right: 1px solid #ececec; }
34
+ .portal-section h2 { border-color: #ececec; color: #5d5d5d; }
35
+ .portal-notice { background: #f0fdf4; border: 1px solid #bbf7d0; }
36
+ .portal-detail, .portal-note { color: #5d5d5d; }
37
+ .portal-button { border-color: #d9d9d9; color: #0d0d0d; }
38
+ .portal-primary { background: #0d0d0d; border-color: #0d0d0d; color: #ffffff; }
39
+ .portal-danger { color: #d00e17; border-color: #f3b4b7; }
40
+ .portal-field input, .portal-field select { border-color: #d9d9d9; }
41
+ `;
42
+ const css = [PORTAL_CSS, OPENAI_SKIN];
43
+ const secretOf = (id) => `sk-proj-${createHash('sha256').update(`project-key:${id}`).digest('hex').slice(0, 48)}`;
44
+ const redacted = (secret) => `${secret.slice(0, 11)}...${secret.slice(-4)}`;
45
+ /** The fields a submission carries: the page's form, as a browser posts it. */
46
+ async function formOf(request) {
47
+ return Object.fromEntries(new URLSearchParams(await request.text()));
48
+ }
49
+ function page(ctx, project, notice, status = 200) {
50
+ const id = String(project.id);
51
+ const keys = ctx.rowsRaw(KEY).filter((k) => k._project_id === id).sort((a, b) => Number(b.created_at) - Number(a.created_at));
52
+ const action = `/settings/${id}/api-keys`;
53
+ return flowPage({
54
+ title: 'API keys - OpenAI API',
55
+ status,
56
+ css,
57
+ body: (_jsx(Portal, { merchant: "OpenAI Platform", back: { href: '/settings/organization/general', label: String(project.name) }, ...(notice ? { notice } : {}), sections: [{
58
+ heading: 'API keys',
59
+ empty: 'This project has no API keys yet.',
60
+ items: keys.map((k) => ({
61
+ title: String(k.name),
62
+ detail: String(k.redacted_value),
63
+ note: `Created ${new Date(Number(k.created_at) * 1000).toISOString().slice(0, 10)} by ${String(k.owner?.user?.name ?? '')}`,
64
+ actions: [{ label: 'Revoke key', action, fields: { revoke: String(k.id) }, tone: 'danger' }],
65
+ })),
66
+ }], forms: [{
67
+ heading: 'Create new secret key',
68
+ action,
69
+ fields: [
70
+ { id: 'name', label: 'Name', placeholder: 'My Test Key' },
71
+ { id: 'permissions', label: 'Permissions', options: PERMISSIONS, value: 'all' },
72
+ ],
73
+ submit: { label: 'Create secret key' },
74
+ }] })),
75
+ });
76
+ }
77
+ /** A request that presents one of the organization's project keys uses it: the key's `last_used_at` is that instant
78
+ * (https://platform.openai.com/docs/api-reference/project-api-keys/object, "The Unix timestamp (in seconds) of when the
79
+ * API key was last used."). A key that has stopped working is refused: one revoked on the keys page or deleted through the
80
+ * Admin API, and one of an archived project ("Archived projects cannot be used or updated",
81
+ * https://platform.openai.com/docs/api-reference/projects/archive). The refusal is OpenAI's incorrect-key answer (the
82
+ * manifest's `auth.invalid`), as OpenAI documents no other for them; the answer is that refusal, or nothing to let the
83
+ * request on. */
84
+ export function projectKeyUse(scope) {
85
+ return async (request) => {
86
+ const bearer = /^Bearer\s+(sk-proj-\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
87
+ if (!bearer)
88
+ return undefined;
89
+ const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), OPERATION, scope);
90
+ const key = ctx.rowsRaw(KEY, { withDeleted: true }).find((k) => secretOf(String(k.id)) === bearer);
91
+ const archived = key ? ctx.row('Project', String(key._project_id))?.status === 'archived' : false;
92
+ if (key?.deleted === true || archived)
93
+ return vendorError(manifest, manifest.auth.invalid);
94
+ if (key)
95
+ await ctx.write(KEY, String(key.id), { last_used_at: epoch(ctx) }, 'api_key.update');
96
+ return undefined;
97
+ };
98
+ }
99
+ /** platform.openai.com/settings/{project}/api-keys, or undefined for any other request. */
100
+ export function openaiApiKeysScreen(scope) {
101
+ return async (request) => {
102
+ const m = /^\/settings\/(proj[-_][A-Za-z0-9_-]+)\/api-keys\/?$/.exec(new URL(request.url).pathname);
103
+ if (!m || (request.method !== 'GET' && request.method !== 'POST'))
104
+ return undefined;
105
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
106
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
107
+ const person = signedIn(request, ctx);
108
+ if (!person)
109
+ return toLogIn(request);
110
+ const project = ctx.get('Project', m[1]);
111
+ if (!project)
112
+ return flowPage({ title: 'OpenAI Platform', status: 404, css, body: _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "Project not found" }) }) });
113
+ if (request.method === 'GET')
114
+ return page(ctx, project);
115
+ if (form.revoke) {
116
+ const key = ctx.row(KEY, form.revoke);
117
+ if (!key || key._project_id !== project.id)
118
+ return page(ctx, project, 'That key no longer exists.', 404);
119
+ await ctx.write(KEY, form.revoke, { deleted: true }, 'api_key.delete');
120
+ return page(ctx, project, `Revoked ${String(key.name)}. Requests using it will now fail.`);
121
+ }
122
+ const name = (form.name ?? '').trim() || 'Secret key';
123
+ const id = ctx.mint(KEY);
124
+ const secret = secretOf(id);
125
+ await ctx.write(KEY, id, {
126
+ object: 'organization.project.api_key', name, redacted_value: redacted(secret), created_at: epoch(ctx), last_used_at: null,
127
+ _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',
128
+ }, 'api_key.create');
129
+ return page(ctx, project, `Save your key: ${secret}. You won't be able to view it again.`, 201);
130
+ };
131
+ }
@@ -0,0 +1,22 @@
1
+ import { type SemanticsContext } from '@volter/world-core';
2
+ type Scope = {
3
+ root?: string;
4
+ clock?: () => string;
5
+ };
6
+ /** A person's account: the user object's fields, and the hash of the password the World gave them. */
7
+ export type Account = {
8
+ id: string;
9
+ object: 'organization.user';
10
+ name: string;
11
+ email: string;
12
+ role: 'owner' | 'reader';
13
+ added_at: number;
14
+ _hash: string;
15
+ };
16
+ /** The account the request's session cookie names, or undefined when nobody is signed in. */
17
+ export declare function signedIn(request: Request, ctx: SemanticsContext): Account | undefined;
18
+ /** Where a dashboard page asked for by nobody signed in sends its visitor: the log-in, returning here after. */
19
+ export declare function toLogIn(request: Request): Response;
20
+ /** The log-in's paths and the World's account door, or undefined for any other request. */
21
+ export declare function openaiSessionFlow(scope: Scope): (request: Request) => Promise<Response | undefined>;
22
+ export {};
@@ -0,0 +1,115 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // OPENAI'S LOG-IN — the door before the dashboard's pages (docs/contributing/architecture.md, "Who is on a screen"): the
3
+ // log-in page takes a person's email address and password, the right pair signs them in, and the dashboard's session
4
+ // cookie names them to every page after. A dashboard page asked for by nobody signed in sends its visitor to the log-in
5
+ // with `return_to`, and the log-in returns them there. Built from @volter/world-ui's sign-in piece under OpenAI's skin;
6
+ // nothing of OpenAI's page is copied.
7
+ //
8
+ // A person is an account of the World's organization, as the Admin API's user object gives one
9
+ // (https://platform.openai.com/docs/api-reference/users/object: id, name, email, role "owner" or "reader", added_at).
10
+ //
11
+ // Where OpenAI's documentation stops and the twin decides: an account is made at the World's door
12
+ // (`POST /_twin/accounts`, standing in for OpenAI's sign-up, which is not modelled), and the World keeps its password's
13
+ // hash, never the password; the World has one organization, whose first account is its owner and whose later ones join
14
+ // 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),
15
+ // served on the dashboard's own host at platform.openai.com/login, the address OpenAI's log-in link opens (OpenAI hands it
16
+ // 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;
17
+ // the session cookie's name (`platform_session`) is the twin's, as OpenAI documents none; a refused log-in shows the page
18
+ // again with the message, answered 200; and a session does not end: no person of a World logs out yet.
19
+ import { createHash } from 'node:crypto';
20
+ import { semanticsContext } from '@volter/world-core';
21
+ import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
22
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
23
+ import { manifest } from "../manifest.js";
24
+ import { epoch } from "../semantics/shared.js";
25
+ const ACCOUNT = '_account';
26
+ const SESSION = '_session';
27
+ const COOKIE = 'platform_session';
28
+ const OPERATION = surface.operations.find((o) => o.id === 'retrieve-user');
29
+ const hex = (s) => createHash('sha256').update(s).digest('hex');
30
+ const passwordHash = (email, password) => hex(`password:${email.toLowerCase()}:${password}`);
31
+ // OpenAI's skin over the sign-in: its black primary button on white
32
+ const SKIN = `
33
+ body { background: #ffffff; color: #0d0d0d; font-family: "Söhne", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
34
+ .sign-in-mark { background: #0d0d0d; }
35
+ .sign-in-error { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
36
+ .sign-in-box { background: #ffffff; border-color: #ececec; }
37
+ .sign-in-box input[type=text], .sign-in-box input[type=password] { border-color: #d9d9d9; }
38
+ .sign-in-submit { background: #0d0d0d; border-color: #0d0d0d; color: #ffffff; }
39
+ `;
40
+ const contextOf = (request, scope) => semanticsContext(manifest, request, OPERATION, scope);
41
+ const accounts = (ctx) => ctx.rowsRaw(ACCOUNT);
42
+ /** The account the request's session cookie names, or undefined when nobody is signed in. */
43
+ export function signedIn(request, ctx) {
44
+ const token = cookieOf(request, COOKIE);
45
+ const session = token ? ctx.rowsRaw(SESSION).find((s) => s.token === token) : undefined;
46
+ return session ? accounts(ctx).find((a) => a.id === session.account) : undefined;
47
+ }
48
+ /** Where a dashboard page asked for by nobody signed in sends its visitor: the log-in, returning here after. */
49
+ export function toLogIn(request) {
50
+ const url = new URL(request.url);
51
+ return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
52
+ }
53
+ /** A return_to the log-in follows: a path on the dashboard, never another site. */
54
+ const returnTo = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
55
+ function page(back, email, error) {
56
+ return flowPage({
57
+ title: 'Log in - OpenAI',
58
+ css: [SIGN_IN_CSS, SKIN],
59
+ body: (_jsx(SignIn, { heading: "Welcome back", action: "/login", fields: { return_to: back }, account: { name: 'email', label: 'Email address', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Continue", ...(error ? { error } : {}) })),
60
+ });
61
+ }
62
+ async function logIn(request, scope) {
63
+ const form = Object.fromEntries(new URLSearchParams(await request.clone().text()));
64
+ const email = (form.email ?? '').trim();
65
+ const back = returnTo(form.return_to);
66
+ const ctx = await contextOf(request, scope);
67
+ const account = accounts(ctx).find((a) => a.email.toLowerCase() === email.toLowerCase());
68
+ // the log-in names neither which half was wrong
69
+ if (!account || account._hash !== passwordHash(account.email, form.password ?? ''))
70
+ return page(back, email, 'Wrong email or password.');
71
+ const n = ctx.rowsRaw(SESSION).length + 1;
72
+ const token = hex(`session:${account.id}:${ctx.occurredAt}:${n}`).slice(0, 48);
73
+ await ctx.record(SESSION, { token, account: account.id, created_at: epoch(ctx) }, `session_${token.slice(0, 16)}`);
74
+ return new Response(null, { status: 302, headers: { location: back, 'set-cookie': `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax` } });
75
+ }
76
+ /** The World's account door refusing an account it cannot make: one already made for the email, or one missing a field. */
77
+ function badAccount(email, taken) {
78
+ 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 });
79
+ }
80
+ /** POST /_twin/accounts {email, name, password, role?} → 201 the account's user object: a person of the World's
81
+ * organization, who logs in with that password. */
82
+ async function makeAccount(request, scope) {
83
+ let body = {};
84
+ try {
85
+ body = await request.clone().json();
86
+ }
87
+ catch { /* answered below */ }
88
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
89
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
90
+ const password = typeof body.password === 'string' ? body.password : '';
91
+ const ctx = await contextOf(request, scope);
92
+ const taken = accounts(ctx).some((a) => a.email.toLowerCase() === email.toLowerCase());
93
+ if (!/^[^@\s]+@[^@\s]+$/.test(email) || !name || !password || taken || (body.role !== undefined && body.role !== 'owner' && body.role !== 'reader'))
94
+ return badAccount(email, taken);
95
+ const role = body.role ?? (accounts(ctx).some((a) => a.role === 'owner') ? 'reader' : 'owner');
96
+ const id = `user-${hex(`account:${email.toLowerCase()}`).slice(0, 24)}`;
97
+ const account = { id, object: 'organization.user', name, email, role, added_at: epoch(ctx), _hash: passwordHash(email, password) };
98
+ await ctx.record(ACCOUNT, account, id);
99
+ const { _hash: _secret, ...user } = account;
100
+ return Response.json(user, { status: 201 });
101
+ }
102
+ /** The log-in's paths and the World's account door, or undefined for any other request. */
103
+ export function openaiSessionFlow(scope) {
104
+ return async (request) => {
105
+ const url = new URL(request.url);
106
+ const path = url.pathname.replace(/\/+$/, '');
107
+ if (path === '/login' && request.method === 'GET')
108
+ return page(returnTo(url.searchParams.get('return_to')));
109
+ if (path === '/login' && request.method === 'POST')
110
+ return logIn(request, scope);
111
+ if (path === '/_twin/accounts' && request.method === 'POST')
112
+ return makeAccount(request, scope);
113
+ return undefined;
114
+ };
115
+ }
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const assistants: Record<string, Semantics>;