@godxjp/sdk 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Entries are split by **who owns the contract**, not by what happens to be duplic
13
13
  | `.` | Platform contracts: permission DTO, tenant-scoped channel names | Binds every product at once |
14
14
  | `./laravel` | House conventions for Laravel + React: HTTP client, `ApiError` | Moves faster |
15
15
  | `./realtime` | Realtime seam: configuration, connection status, channel listeners | Moves faster |
16
+ | `./agent` | Server side: who is calling (person, workload, AI agent), effect keys | Binds every product at once |
16
17
 
17
18
  ## Scope — what belongs here, and what does not
18
19
 
@@ -155,6 +156,117 @@ An unreachable server (`status: 0`) keeps its built-in text, `Could not reach th
155
156
 
156
157
  **Changed in 0.4.0.** Additions only; a client configured for 0.3.0 sends and throws exactly what it did. `ApiError` gains `body` (also on a `download()` failure). `onUnauthorized` now receives the request that was answered 401 (`{ method, url, headers }`); a function that takes no argument still works. `networkErrors: "throw"` rethrows `fetch`'s own error instead of `ApiError(0)` — including a response body that breaks off mid-way.
157
158
 
159
+ ### Replacing a hand-written `session-expiry.ts`
160
+
161
+ A product that wrapped `fetch` to send the browser to sign in on an expired session does not need that file: the client already does it.
162
+
163
+ ```ts
164
+ export const apiClient = createClient({
165
+ baseUrl: "/api/app/v1",
166
+ signInUrl: (returnTo) => `/login?return=${encodeURIComponent(returnTo)}`,
167
+ });
168
+ ```
169
+
170
+ What happens on a **401**, in order:
171
+
172
+ 1. If `onUnauthorized` is given, it is called with the request that was answered 401. Resolve `true` (a token was refreshed) and the request is sent once more — never more than once; resolve `false` and the 401 goes on to step 2. 401s that arrive together share one call.
173
+ 2. If `signInUrl` is given, the browser goes to `signInUrl(pathname + search)` with `window.location.assign` — once, however many requests were answered 401 at the same moment. If the user comes Back and the page is restored from the back/forward cache (`pageshow` with `persisted`), the next 401 sends them again.
174
+ 3. The call still rejects with `ApiError(401)`, so a screen that was waiting does not hang.
175
+
176
+ A **419** (CSRF token expired) is **not** redirected: it rejects with `ApiError(419)` and nothing else. With php-sdk's `SignInErrors::handle`, the JSON body is `{ error: "session_expired", message, request_id }`, so read `error.body` when a screen should react:
177
+
178
+ ```ts
179
+ if (error instanceof ApiError && error.status === 419) {
180
+ window.location.reload(); // or show the message from error.body
181
+ }
182
+ ```
183
+
184
+ ## `./errors` — the shared sign-in error page
185
+
186
+ **New in 0.6.0.** Every GoDX service shows the same page, with the same words, for a failed sign-in or a refused access: not a member of the organization, no entitlement, inactive subscription, locked or unverified account, a broken SSO callback, the platform not answering, an expired session, a 403 inside the app.
187
+
188
+ php-sdk owns the codes, the ja/vi/en sentences and the redirect to the error route; this entry only types the payload and renders it. The page has **no copy of its own** — title, description, the IdP's detail and every action label arrive translated in the payload.
189
+
190
+ ```tsx
191
+ // resources/js/pages/GodxSignInError.tsx — the whole file
192
+ export { SignInErrorPage as default } from "@godxjp/sdk/errors";
193
+ ```
194
+
195
+ The `error` prop (php-sdk's `SignInErrorPresenter`):
196
+
197
+ ```ts
198
+ type SignInErrorPayload = {
199
+ code: SignInErrorCode; // see SIGN_IN_ERROR_CODES
200
+ status: number; // 401, 403, 419, 429, 503 …
201
+ title: string;
202
+ description: string;
203
+ detail: string | null; // the IdP's error_description — from the session, never the URL
204
+ request_id: string | null;
205
+ action: { kind: "retry" | "switch_organization" | "home"; href: string; label: string } | null;
206
+ };
207
+ ```
208
+
209
+ - `action` is the page's one call to action, a link to `href` labelled `label`; `null` renders no button.
210
+ - `request_id` is shown as a labelled support id; `detail`, when present, follows the description.
211
+ - `status` is shown as sent. The page is built only from public `@godxjp/ui` components (`ErrorSurface` in `system` mode and `Button`); ErrorSurface draws a status it has no curated glyph for (401, 419, 429) with its 4xx fallback.
212
+ - `SIGN_IN_ERROR_CODES` lists every code; codes are only ever added.
213
+
214
+ ## `./agent` — AI agents as callers
215
+
216
+ Server-side and framework-free: no DOM, no React. For a service that receives GoDX access tokens (godx-jp/id#2066, #2074, #2075).
217
+
218
+ An AI agent's token says `prn: "agent"` and `sub` = the **agent** — never a person. The person who asked and the person who answers for the agent come in `act: { sub: <requester>, act: { sub: <sponsor> } }`; a write also carries its one `gx_execution_id` and `gx_effect_key`.
219
+
220
+ ```ts
221
+ import {
222
+ principalFromIntrospection,
223
+ principalFromClaims,
224
+ admitPrincipal,
225
+ actorId,
226
+ responsibleUserId,
227
+ PrincipalError,
228
+ } from "@godxjp/sdk/agent";
229
+
230
+ // From Platform's RFC 7662 answer — or principalFromClaims(claims) for claims your server already verified.
231
+ const principal = principalFromIntrospection(answer, {
232
+ issuer: "https://id.godx.jp",
233
+ resource: "https://task.godx.jp/mcp", // an agent's token carries the publication's resource URI
234
+ allowAgents: true, // this route's explicit opt-in; off by default
235
+ });
236
+
237
+ audit.record({ actor: actorId(principal), responsible: responsibleUserId(principal) });
238
+ ```
239
+
240
+ - A `prn` other than `user`, `workload` or `agent` is refused (`PrincipalError`, `status: 401`). An agent on a route without `allowAgents: true` is refused with `status: 403`, `code: "agent_not_admitted"`; `admitPrincipal(principal, { allowAgents })` is the same guard for a principal you already hold.
241
+ - An agent is refused unless `act` is exactly `{requester, {sponsor}}`, `requester_user_id` repeats `act.sub`, the profile is `gx_agent_service_v1`, it is DPoP-bound, and an effect key comes with its execution. From introspection, `service_authorization` must also name the same agent, requester, sponsor and run.
242
+ - `actorId()` is who did it (the agent); `responsibleUserId()` is who answers for it — the requester for an agent, the user for a user, `null` for a workload. Never read `subject` as a person without `isAgent()`.
243
+ - Authorization stays the service's own live check of the agent's permissions, exactly as for a person.
244
+
245
+ ### Applying a write once per effect key
246
+
247
+ Platform cannot see the service's database, so only the service can make "the same effect twice" impossible: commit a row keyed on the effect key in the same transaction as the write. Back `EffectLedger` with one table whose primary key is the effect key, and `insertIfAbsent` with `INSERT … ON CONFLICT DO NOTHING`:
248
+
249
+ ```ts
250
+ import { applyEffect, createExecutionClient, EffectRefused } from "@godxjp/sdk/agent";
251
+
252
+ const executions = createExecutionClient({ issuer: "https://id.godx.jp", clientId, clientSecret });
253
+
254
+ const effect = await applyEffect({
255
+ ledger, // your EffectLedger: find / transaction(insertIfAbsent, saveResult) / markReported
256
+ executions,
257
+ principal,
258
+ accessToken, // sent to Platform's checkpoint only — never forward it to another service
259
+ tool: "issue_create",
260
+ arguments: input,
261
+ write: async (tx) => ({ issue_id: await tx.insertIssue(input) }), // every write of the effect, on tx
262
+ });
263
+ // effect.replayed: already applied under this key; the write did not run again.
264
+ ```
265
+
266
+ `applyEffect` answers a replay from the ledger first (retrying an unsent report), then asks Platform's execution checkpoint as the service and requires it to name this execution and key, runs the write once with the ledger row (`runOnce`), and reports the outcome — `failed` when the write throws, which is then rethrown. `EffectRefused` means nothing was written.
267
+
268
+ The PHP SDK (`godx-jp/php-sdk`) carries the same contract, a table-backed ledger and the "agent-ready" contract test kit a service runs before Platform grants agents its tools.
269
+
158
270
  ## `./realtime`
159
271
 
160
272
  ```ts
@@ -223,6 +335,8 @@ Build with `organizationChannel` / `platformChannel`, parse with `parseChannelNa
223
335
 
224
336
  `./realtime` needs `@laravel/echo-react` and `react`. Both are optional peers: an application using only `./laravel` installs neither.
225
337
 
338
+ `./errors` needs `@godxjp/ui` (19.2.0 or later — the first release whose `ErrorSurface` takes any status — below 34) and `react`, both optional peers too. It imports no stylesheet: the application already loads `@godxjp/ui/styles`, and the page should sit inside its `AppProvider` so the kit's own labels (such as "Request ID") follow the locale.
339
+
226
340
  ## Licence
227
341
 
228
342
  Apache-2.0.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Who is calling a GoDX service — a person, a workload, or an AI agent acting for a person
3
+ * (godx-jp/id#2066, #2074, #2075).
4
+ *
5
+ * An agent's token says `prn: "agent"` and `sub` = the AGENT, never a person. The person at the
6
+ * head of the chain and the person who answers for the agent come in `act`:
7
+ *
8
+ * act = { sub: <requester>, act: { sub: <sponsor> } }
9
+ *
10
+ * Read a principal only through {@link principalFromClaims} (claims your server already verified)
11
+ * or {@link principalFromIntrospection} (Platform's RFC 7662 answer). Both refuse a `prn` they do
12
+ * not know, and refuse an agent unless the caller opts in with `allowAgents: true`.
13
+ */
14
+ type PrincipalKind = "user" | "workload" | "agent";
15
+ interface Principal {
16
+ readonly kind: PrincipalKind;
17
+ /** The token's `sub`: the user, the workload — or, for an agent, the AGENT. */
18
+ readonly subject: string;
19
+ /** The organization context the token is scoped to (`organization_context_id`, else `organization_id`). */
20
+ readonly organization: string;
21
+ /** Platform's organization id (`organization_id`), when the token carries one. */
22
+ readonly organizationId: string | null;
23
+ readonly clientId: string;
24
+ readonly tokenId: string;
25
+ readonly scopes: readonly string[];
26
+ /** Agent only: the person who asked for the work (`act.sub`). */
27
+ readonly requesterUserId: string | null;
28
+ /** Agent only: the person who answers for the agent in this organization (`act.act.sub`). */
29
+ readonly sponsorUserId: string | null;
30
+ /** Agent only: the run the call belongs to. */
31
+ readonly agentRunId: string | null;
32
+ /** A write binding's ONE execution (`gx_execution_id`). */
33
+ readonly executionId: string | null;
34
+ /** Agent write only: the key the service must deduplicate the write on (`gx_effect_key`). */
35
+ readonly effectKey: string | null;
36
+ }
37
+ type PrincipalErrorCode = "inactive" | "unknown_principal" | "agent_not_admitted" | "malformed_token" | "malformed_agent" | "context_mismatch";
38
+ declare class PrincipalError extends Error {
39
+ readonly code: PrincipalErrorCode;
40
+ /** 403 for an agent on a route that did not opt in; 401 for everything else. */
41
+ readonly status: 401 | 403;
42
+ constructor(code: PrincipalErrorCode, message: string);
43
+ }
44
+ interface PrincipalOptions {
45
+ /** The route's explicit opt-in for AI agents. Off by default. */
46
+ allowAgents?: boolean;
47
+ }
48
+ interface IntrospectionOptions extends PrincipalOptions {
49
+ /** Platform's issuer, exactly as it signs `iss`. */
50
+ issuer: string;
51
+ /** This resource's audience — for an agent, the publication's resource URI. */
52
+ resource: string;
53
+ }
54
+ /** The only token profile an agent's `prn: "agent"` may arrive under. */
55
+ declare const AGENT_ACCESS_POLICY = "gx_agent_service_v1";
56
+ declare function isAgent(principal: Principal): boolean;
57
+ /** Who performed the call: the agent, the user or the workload. Record it as the actor. */
58
+ declare function actorId(principal: Principal): string;
59
+ /** Who answers for the call: the requester for an agent, the user for a user, nobody for a workload. */
60
+ declare function responsibleUserId(principal: Principal): string | null;
61
+ /**
62
+ * The guard a route puts in front of its handler: refuses an agent unless the route opted in.
63
+ * Unknown lanes never get this far — the parsers refuse them.
64
+ */
65
+ declare function admitPrincipal(principal: Principal, options?: PrincipalOptions): Principal;
66
+ /**
67
+ * A principal from access-token claims the server has ALREADY verified (signature, issuer,
68
+ * audience, lifetime, DPoP). This function checks the lane and the agent chain, not the signature.
69
+ */
70
+ declare function principalFromClaims(claims: unknown, options?: PrincipalOptions): Principal;
71
+ /**
72
+ * A principal from Platform's introspection answer (RFC 7662). Beyond {@link principalFromClaims}
73
+ * it requires the token active, issued by `issuer` for `resource`, and — for an agent — that
74
+ * `service_authorization` describes the SAME caller Platform resolves now: `principal: "agent"`,
75
+ * the agent, requester, sponsor, run, execution and effect key.
76
+ */
77
+ declare function principalFromIntrospection(response: unknown, options: IntrospectionOptions): Principal;
78
+
79
+ /**
80
+ * The resource instance's side of a mutation binding call (godx-jp/id#1447, #2074).
81
+ *
82
+ * Both calls authenticate with the SERVICE's own confidential client, never with the caller's
83
+ * token: `checkpoint` is asked before every write, `outcome` once at the end. The caller's token
84
+ * travels in the checkpoint BODY, to the issuer that minted it — the only place it may ever go.
85
+ */
86
+ declare const CHECKPOINT_PATH = "api/gx/v1/executions/checkpoints";
87
+ declare const OUTCOME_PATH = "api/gx/v1/executions/outcome";
88
+ interface ExecutionClientConfig {
89
+ /** Platform's issuer, e.g. `https://id.godx.jp`. */
90
+ issuer: string;
91
+ /** This service instance's client credential. */
92
+ clientId: string;
93
+ clientSecret: string;
94
+ /** Defaults to the global `fetch`. */
95
+ fetch?: typeof fetch;
96
+ }
97
+ interface CheckpointRequest {
98
+ accessToken: string;
99
+ /** What kind of effect is about to happen, e.g. `write`. */
100
+ effect: string;
101
+ tool: string;
102
+ /** The tool input exactly as the person approved it; Platform compares its digest. */
103
+ arguments: Record<string, unknown>;
104
+ }
105
+ interface CheckpointDecision {
106
+ allowed: boolean;
107
+ /** `allowed`, Platform's refusal reason (`already_applied`, `mismatch`, `not_running`, …), or `unavailable` / `invalid_response`. */
108
+ reason: string;
109
+ executionId: string | null;
110
+ sequence: number | null;
111
+ effectKey: string | null;
112
+ /** On `already_applied`: the outcome the service reported the first time. */
113
+ outcome: Record<string, unknown> | null;
114
+ }
115
+ interface OutcomeReport {
116
+ executionId: string;
117
+ succeeded: boolean;
118
+ /** At most 16 KiB of JSON; Platform returns it on a replayed checkpoint. */
119
+ outcome?: Record<string, unknown>;
120
+ }
121
+ interface ExecutionClient {
122
+ /** Fails closed: a refusal, an outage or a malformed answer is `allowed: false`. */
123
+ checkpoint(request: CheckpointRequest): Promise<CheckpointDecision>;
124
+ /** Resolves `true` once Platform recorded it — also when it was already recorded (409). */
125
+ outcome(report: OutcomeReport): Promise<boolean>;
126
+ }
127
+ declare function createExecutionClient(config: ExecutionClientConfig): ExecutionClient;
128
+
129
+ /**
130
+ * Apply an AI agent's write AT MOST ONCE per effect key (godx-jp/id#2074, #2075).
131
+ *
132
+ * Platform mints one `effect_key` per approved write and cannot see the service's database, so
133
+ * only the service can make "the same effect twice" impossible — by committing a row keyed on the
134
+ * effect key IN THE SAME TRANSACTION as the write. This module owns the protocol; the service
135
+ * backs {@link EffectLedger} with its own database (one table with the effect key as primary key).
136
+ */
137
+
138
+ interface EffectRecord {
139
+ effectKey: string;
140
+ executionId: string;
141
+ agentId: string;
142
+ requesterUserId: string;
143
+ organization: string;
144
+ /** What the first run returned; replayed as-is. */
145
+ result: Record<string, unknown> | null;
146
+ /** Whether Platform has the outcome; a replay retries the report when it does not. */
147
+ reported: boolean;
148
+ }
149
+ /** What the service's transaction offers the ledger. Everything runs on the service's own connection. */
150
+ interface EffectLedgerTransaction {
151
+ /**
152
+ * Insert `record` unless its effect key exists (`INSERT … ON CONFLICT DO NOTHING`). Resolve null
153
+ * when THIS call inserted it, the existing record otherwise. A concurrent insert of the same key
154
+ * must wait for the other transaction — a primary key gives exactly that.
155
+ */
156
+ insertIfAbsent(record: EffectRecord): Promise<EffectRecord | null>;
157
+ saveResult(effectKey: string, result: Record<string, unknown> | null): Promise<void>;
158
+ }
159
+ /** `Tx` is the service's own transaction handle, extended with the two ledger operations. */
160
+ interface EffectLedger<Tx extends EffectLedgerTransaction = EffectLedgerTransaction> {
161
+ find(effectKey: string): Promise<EffectRecord | null>;
162
+ /** Run `work` in ONE transaction of the service's database; a throw rolls everything back. */
163
+ transaction<R>(work: (tx: Tx) => Promise<R>): Promise<R>;
164
+ markReported(effectKey: string): Promise<void>;
165
+ }
166
+ interface EffectResult {
167
+ /** True when the key was already applied and the write did not run again. */
168
+ replayed: boolean;
169
+ result: Record<string, unknown> | null;
170
+ reported: boolean;
171
+ }
172
+ declare class EffectRefused extends Error {
173
+ readonly reason: string;
174
+ constructor(reason: string);
175
+ }
176
+ type EffectWrite<T> = (tx: T) => Promise<Record<string, unknown> | null>;
177
+ /**
178
+ * The deduplication alone: run `write` once per effect key, inside the ledger's transaction.
179
+ * `write` receives the transaction so every database write of the effect commits with the row.
180
+ */
181
+ declare function runOnce<Tx extends EffectLedgerTransaction>(ledger: EffectLedger<Tx>, principal: Principal, write: EffectWrite<Tx>): Promise<EffectResult>;
182
+ interface ApplyEffectRequest<Tx extends EffectLedgerTransaction> {
183
+ ledger: EffectLedger<Tx>;
184
+ executions: ExecutionClient;
185
+ principal: Principal;
186
+ /** The incoming token — sent to Platform's checkpoint only, never anywhere else. */
187
+ accessToken: string;
188
+ tool: string;
189
+ arguments: Record<string, unknown>;
190
+ write: EffectWrite<Tx>;
191
+ effect?: string;
192
+ }
193
+ /**
194
+ * The whole lane: a replay is answered from the ledger (and its unsent report retried); otherwise
195
+ * Platform's checkpoint must allow THIS execution and key, the write runs once, and the outcome is
196
+ * reported `succeeded` — or `failed` when the write throws, which is then rethrown.
197
+ */
198
+ declare function applyEffect<Tx extends EffectLedgerTransaction>(request: ApplyEffectRequest<Tx>): Promise<EffectResult>;
199
+
200
+ export { AGENT_ACCESS_POLICY, type ApplyEffectRequest, CHECKPOINT_PATH, type CheckpointDecision, type CheckpointRequest, type EffectLedger, type EffectLedgerTransaction, type EffectRecord, EffectRefused, type EffectResult, type EffectWrite, type ExecutionClient, type ExecutionClientConfig, type IntrospectionOptions, OUTCOME_PATH, type OutcomeReport, type Principal, PrincipalError, type PrincipalErrorCode, type PrincipalKind, type PrincipalOptions, actorId, admitPrincipal, applyEffect, createExecutionClient, isAgent, principalFromClaims, principalFromIntrospection, responsibleUserId, runOnce };
@@ -0,0 +1,362 @@
1
+ // src/agent/principal.ts
2
+ var PrincipalError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "PrincipalError";
7
+ this.status = code === "agent_not_admitted" ? 403 : 401;
8
+ }
9
+ code;
10
+ /** 403 for an agent on a route that did not opt in; 401 for everything else. */
11
+ status;
12
+ };
13
+ var AGENT_ACCESS_POLICY = "gx_agent_service_v1";
14
+ var KINDS = ["user", "workload", "agent"];
15
+ var AGENT_ONLY_CLAIMS = ["requester_user_id", "agent_run_id", "gx_effect_key"];
16
+ function isAgent(principal) {
17
+ return principal.kind === "agent";
18
+ }
19
+ function actorId(principal) {
20
+ return principal.subject;
21
+ }
22
+ function responsibleUserId(principal) {
23
+ switch (principal.kind) {
24
+ case "agent":
25
+ return principal.requesterUserId;
26
+ case "user":
27
+ return principal.subject;
28
+ case "workload":
29
+ return null;
30
+ }
31
+ }
32
+ function admitPrincipal(principal, options = {}) {
33
+ if (principal.kind === "agent" && options.allowAgents !== true) {
34
+ throw new PrincipalError("agent_not_admitted", "This route does not admit agent principals.");
35
+ }
36
+ return principal;
37
+ }
38
+ function principalFromClaims(claims, options = {}) {
39
+ const record = asRecord(claims);
40
+ if (record === null) {
41
+ throw new PrincipalError("malformed_token", "Token claims must be an object.");
42
+ }
43
+ const kind = laneOf(record["prn"]);
44
+ const subject = requiredString(record, "sub");
45
+ const organization = optionalString(record, "organization_context_id") ?? optionalString(record, "organization_id");
46
+ if (organization === null) {
47
+ throw new PrincipalError("malformed_token", "The token names no organization.");
48
+ }
49
+ const scope = record["scope"] ?? "";
50
+ if (typeof scope !== "string") {
51
+ throw new PrincipalError("malformed_token", "Malformed token scopes.");
52
+ }
53
+ const base = {
54
+ kind,
55
+ subject,
56
+ organization,
57
+ organizationId: optionalString(record, "organization_id"),
58
+ clientId: requiredString(record, "client_id"),
59
+ tokenId: requiredString(record, "jti"),
60
+ scopes: scope.split(" ").filter((value) => value !== ""),
61
+ executionId: optionalString(record, "gx_execution_id")
62
+ };
63
+ if (kind !== "agent") {
64
+ for (const claim of AGENT_ONLY_CLAIMS) {
65
+ if (record[claim] !== void 0 && record[claim] !== null) {
66
+ throw new PrincipalError("malformed_token", "Agent claims on a token that is not an agent's.");
67
+ }
68
+ }
69
+ return Object.freeze({ ...base, requesterUserId: null, sponsorUserId: null, agentRunId: null, effectKey: null });
70
+ }
71
+ if (options.allowAgents !== true) {
72
+ throw new PrincipalError("agent_not_admitted", "This route does not admit agent principals.");
73
+ }
74
+ const [requester, sponsor] = agentChain(record);
75
+ const effectKey = optionalString(record, "gx_effect_key");
76
+ if (record["access_policy"] !== AGENT_ACCESS_POLICY || base.organizationId === null) {
77
+ throw new PrincipalError("malformed_agent", "Malformed agent token.");
78
+ }
79
+ if (requester === subject) {
80
+ throw new PrincipalError("malformed_agent", "An agent is never its own requester.");
81
+ }
82
+ if (base.executionId === null !== (effectKey === null)) {
83
+ throw new PrincipalError("malformed_agent", "An agent write names both its execution and its effect key.");
84
+ }
85
+ if (asRecord(record["cnf"]) === null) {
86
+ throw new PrincipalError("malformed_agent", "An agent token must be DPoP-bound.");
87
+ }
88
+ return Object.freeze({
89
+ ...base,
90
+ requesterUserId: requester,
91
+ sponsorUserId: sponsor,
92
+ agentRunId: requiredString(record, "agent_run_id"),
93
+ effectKey
94
+ });
95
+ }
96
+ function principalFromIntrospection(response, options) {
97
+ const record = asRecord(response);
98
+ if (record === null || record["active"] !== true) {
99
+ throw new PrincipalError("inactive", "The token is not active.");
100
+ }
101
+ if (record["iss"] !== options.issuer) {
102
+ throw new PrincipalError("context_mismatch", "Token issuer mismatch.");
103
+ }
104
+ const audiences = Array.isArray(record["aud"]) ? record["aud"] : [record["aud"]];
105
+ if (!audiences.includes(options.resource)) {
106
+ throw new PrincipalError("context_mismatch", "Token audience mismatch.");
107
+ }
108
+ const authorization = asRecord(record["service_authorization"]);
109
+ const agentAuthority = record["prn"] === "agent" && authorization?.["principal"] === "agent";
110
+ const principal = principalFromClaims(
111
+ {
112
+ ...record,
113
+ access_policy: agentAuthority ? AGENT_ACCESS_POLICY : record["access_policy"],
114
+ gx_execution_id: authorization?.["execution_id"] ?? record["gx_execution_id"],
115
+ gx_effect_key: authorization?.["effect_key"] ?? record["gx_effect_key"]
116
+ },
117
+ options
118
+ );
119
+ if (principal.kind !== "agent") {
120
+ if (authorization !== null && (authorization["principal"] ?? "user") !== "user") {
121
+ throw new PrincipalError("context_mismatch", "The authority describes another caller.");
122
+ }
123
+ return principal;
124
+ }
125
+ if (authorization === null || authorization["agent_id"] !== principal.subject || authorization["requester_user_id"] !== principal.requesterUserId || authorization["sponsor_user_id"] !== principal.sponsorUserId || authorization["agent_run_id"] !== principal.agentRunId) {
126
+ throw new PrincipalError("context_mismatch", "The authority describes another caller.");
127
+ }
128
+ return principal;
129
+ }
130
+ function laneOf(value) {
131
+ if (typeof value === "string" && KINDS.includes(value)) {
132
+ return value;
133
+ }
134
+ throw new PrincipalError("unknown_principal", "This credential lane cannot access a consumer resource.");
135
+ }
136
+ function agentChain(record) {
137
+ const act = asRecord(record["act"]);
138
+ const sponsor = act === null ? null : asRecord(act["act"]);
139
+ const requester = act?.["sub"];
140
+ if (act === null || sponsor === null || "act" in sponsor || typeof requester !== "string" || requester === "" || typeof sponsor["sub"] !== "string" || sponsor["sub"] === "" || record["requester_user_id"] !== requester || typeof record["agent_run_id"] !== "string" || record["agent_run_id"] === "") {
141
+ throw new PrincipalError("malformed_agent", "Malformed agent actor chain.");
142
+ }
143
+ return [requester, sponsor["sub"]];
144
+ }
145
+ function asRecord(value) {
146
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
147
+ }
148
+ function requiredString(record, name) {
149
+ const value = record[name];
150
+ if (typeof value !== "string" || value === "") {
151
+ throw new PrincipalError("malformed_token", `Missing token claim: ${name}.`);
152
+ }
153
+ return value;
154
+ }
155
+ function optionalString(record, name) {
156
+ const value = record[name];
157
+ if (value === void 0 || value === null) {
158
+ return null;
159
+ }
160
+ if (typeof value !== "string" || value === "") {
161
+ throw new PrincipalError("malformed_token", `Malformed token claim: ${name}.`);
162
+ }
163
+ return value;
164
+ }
165
+
166
+ // src/agent/execution-client.ts
167
+ var CHECKPOINT_PATH = "api/gx/v1/executions/checkpoints";
168
+ var OUTCOME_PATH = "api/gx/v1/executions/outcome";
169
+ function createExecutionClient(config) {
170
+ const send = config.fetch ?? fetch;
171
+ const base = config.issuer.replace(/\/+$/, "") + "/";
172
+ if (!base.startsWith("https://")) {
173
+ throw new Error("The execution client needs an https issuer.");
174
+ }
175
+ const post = (path, body) => send(base + path, {
176
+ method: "POST",
177
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
178
+ body: JSON.stringify({ client_id: config.clientId, client_secret: config.clientSecret, ...body }),
179
+ redirect: "error"
180
+ });
181
+ return {
182
+ async checkpoint(request) {
183
+ const refused = (reason, outcome = null) => ({
184
+ allowed: false,
185
+ reason,
186
+ executionId: null,
187
+ sequence: null,
188
+ effectKey: null,
189
+ outcome
190
+ });
191
+ if (request.accessToken === "" || request.effect === "" || request.tool === "") {
192
+ return refused("invalid_request");
193
+ }
194
+ let response;
195
+ try {
196
+ response = await post(CHECKPOINT_PATH, {
197
+ token: request.accessToken,
198
+ effect: request.effect,
199
+ tool: request.tool,
200
+ arguments: request.arguments
201
+ });
202
+ } catch {
203
+ return refused("unavailable");
204
+ }
205
+ if (response.status === 429 || response.status >= 500) {
206
+ return refused("unavailable");
207
+ }
208
+ const data = await readJson(response);
209
+ if (response.status === 403) {
210
+ const reason = data?.["reason"];
211
+ const outcome = data?.["outcome"];
212
+ return refused(typeof reason === "string" && reason !== "" ? reason : "forbidden", isRecord(outcome) ? outcome : null);
213
+ }
214
+ const executionId = data?.["execution_id"];
215
+ const sequence = data?.["sequence"] ?? null;
216
+ const effectKey = data?.["effect_key"] ?? null;
217
+ if (response.status !== 200 || data?.["allowed"] !== true || typeof executionId !== "string" || executionId === "" || sequence !== null && !Number.isInteger(sequence) || effectKey !== null && (typeof effectKey !== "string" || effectKey === "")) {
218
+ return refused(response.ok ? "invalid_response" : "forbidden");
219
+ }
220
+ return {
221
+ allowed: true,
222
+ reason: "allowed",
223
+ executionId,
224
+ sequence,
225
+ effectKey,
226
+ outcome: null
227
+ };
228
+ },
229
+ async outcome(report) {
230
+ try {
231
+ const response = await post(OUTCOME_PATH, {
232
+ execution_id: report.executionId,
233
+ result: report.succeeded ? "succeeded" : "failed",
234
+ outcome: report.outcome ?? {}
235
+ });
236
+ return response.ok || response.status === 409;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+ };
242
+ }
243
+ async function readJson(response) {
244
+ try {
245
+ const data = await response.json();
246
+ return isRecord(data) ? data : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ function isRecord(value) {
252
+ return typeof value === "object" && value !== null && !Array.isArray(value);
253
+ }
254
+
255
+ // src/agent/effects.ts
256
+ var EffectRefused = class extends Error {
257
+ constructor(reason) {
258
+ super(`Agent effect refused: ${reason}.`);
259
+ this.reason = reason;
260
+ this.name = "EffectRefused";
261
+ }
262
+ reason;
263
+ };
264
+ async function runOnce(ledger, principal, write) {
265
+ const key = effectKeyOf(principal);
266
+ return ledger.transaction(async (tx) => {
267
+ const existing = await tx.insertIfAbsent({
268
+ effectKey: key,
269
+ executionId: principal.executionId,
270
+ agentId: principal.subject,
271
+ requesterUserId: principal.requesterUserId,
272
+ organization: principal.organization,
273
+ result: null,
274
+ reported: false
275
+ });
276
+ if (existing !== null) {
277
+ assertSameCaller(principal, existing);
278
+ return { replayed: true, result: existing.result, reported: existing.reported };
279
+ }
280
+ const result = await write(tx);
281
+ if (result !== null && (typeof result !== "object" || Array.isArray(result))) {
282
+ throw new EffectRefused("write_result_not_an_object");
283
+ }
284
+ await tx.saveResult(key, result);
285
+ return { replayed: false, result, reported: false };
286
+ });
287
+ }
288
+ async function applyEffect(request) {
289
+ const { ledger, executions, principal } = request;
290
+ const key = effectKeyOf(principal);
291
+ const executionId = principal.executionId;
292
+ const recorded = await ledger.find(key);
293
+ if (recorded !== null) {
294
+ assertSameCaller(principal, recorded);
295
+ return replay(request, recorded);
296
+ }
297
+ const decision = await executions.checkpoint({
298
+ accessToken: request.accessToken,
299
+ effect: request.effect ?? "write",
300
+ tool: request.tool,
301
+ arguments: request.arguments
302
+ });
303
+ if (!decision.allowed) {
304
+ throw new EffectRefused(decision.reason);
305
+ }
306
+ if (decision.executionId !== executionId || decision.effectKey !== key) {
307
+ throw new EffectRefused("execution_mismatch");
308
+ }
309
+ let applied;
310
+ try {
311
+ applied = await runOnce(ledger, principal, request.write);
312
+ } catch (error) {
313
+ if (!(error instanceof EffectRefused)) {
314
+ await executions.outcome({ executionId, succeeded: false, outcome: { error: "write_failed" } });
315
+ }
316
+ throw error;
317
+ }
318
+ if (applied.replayed) {
319
+ return replay(request, { ...recordOf(principal), result: applied.result, reported: applied.reported });
320
+ }
321
+ const reported = await executions.outcome({ executionId, succeeded: true, outcome: applied.result ?? {} });
322
+ if (reported) {
323
+ await ledger.markReported(key);
324
+ }
325
+ return { replayed: false, result: applied.result, reported };
326
+ }
327
+ async function replay(request, recorded) {
328
+ let reported = recorded.reported;
329
+ if (!reported) {
330
+ reported = await request.executions.outcome({
331
+ executionId: recorded.executionId,
332
+ succeeded: true,
333
+ outcome: recorded.result ?? {}
334
+ });
335
+ if (reported) {
336
+ await request.ledger.markReported(recorded.effectKey);
337
+ }
338
+ }
339
+ return { replayed: true, result: recorded.result, reported };
340
+ }
341
+ function effectKeyOf(principal) {
342
+ if (principal.kind !== "agent" || principal.effectKey === null || principal.executionId === null) {
343
+ throw new EffectRefused("no_effect_key");
344
+ }
345
+ return principal.effectKey;
346
+ }
347
+ function assertSameCaller(principal, record) {
348
+ if (record.agentId !== principal.subject || record.executionId !== principal.executionId || record.organization !== principal.organization) {
349
+ throw new EffectRefused("effect_key_conflict");
350
+ }
351
+ }
352
+ function recordOf(principal) {
353
+ return {
354
+ effectKey: principal.effectKey,
355
+ executionId: principal.executionId,
356
+ agentId: principal.subject,
357
+ requesterUserId: principal.requesterUserId,
358
+ organization: principal.organization
359
+ };
360
+ }
361
+
362
+ export { AGENT_ACCESS_POLICY, CHECKPOINT_PATH, EffectRefused, OUTCOME_PATH, PrincipalError, actorId, admitPrincipal, applyEffect, createExecutionClient, isAgent, principalFromClaims, principalFromIntrospection, responsibleUserId, runOnce };
@@ -0,0 +1,50 @@
1
+ import * as react from 'react';
2
+
3
+ /**
4
+ * The shared sign-in / access error vocabulary. php-sdk (`SignInErrorCode`) is the source of truth;
5
+ * this mirrors it so a page that renders the payload is typed against the same codes.
6
+ *
7
+ * Codes are only ever ADDED, never renamed or given a new meaning.
8
+ */
9
+ declare const SIGN_IN_ERROR_CODES: readonly ["organization_membership_missing", "organization_unavailable", "organization_context_missing", "service_entitlement_missing", "subscription_inactive", "personal_workspace_not_enabled", "user_inactive", "user_unverified", "service_unavailable", "employee_onboarding", "access_denied", "callback_invalid", "token_exchange_failed", "platform_unavailable", "session_expired", "forbidden", "too_many_attempts"];
10
+ type SignInErrorCode = (typeof SIGN_IN_ERROR_CODES)[number];
11
+ type SignInErrorActionKind = "retry" | "switch_organization" | "home";
12
+ /**
13
+ * The one way out of the error page. `label` is already translated by php-sdk for the request's
14
+ * locale — the page has no copy of its own, not even for the action button.
15
+ */
16
+ type SignInErrorAction = {
17
+ kind: SignInErrorActionKind;
18
+ href: string;
19
+ label: string;
20
+ };
21
+ /**
22
+ * What php-sdk's `SignInErrorPresenter` hands the page (the `error` prop). Every sentence is
23
+ * already translated.
24
+ */
25
+ type SignInErrorPayload = {
26
+ code: SignInErrorCode;
27
+ status: number;
28
+ title: string;
29
+ description: string;
30
+ /** The IdP's own `error_description`. Only ever carried through the session, never the URL. */
31
+ detail: string | null;
32
+ request_id: string | null;
33
+ /** `null` renders no button. */
34
+ action: SignInErrorAction | null;
35
+ };
36
+
37
+ type SignInErrorPageProps = {
38
+ error: SignInErrorPayload;
39
+ };
40
+ /**
41
+ * The page every GoDX service shows for a failed sign-in or a refused access. It owns no copy:
42
+ * title, description, detail and the action label arrive translated in `error`.
43
+ *
44
+ * Always `mode="system"` — the page is reached from a sign-in callback or a redirect, where there
45
+ * is no application shell to keep. `status` is passed through as sent; ErrorSurface draws any
46
+ * status it has no curated glyph for (401, 419, 429) with its 4xx fallback.
47
+ */
48
+ declare function SignInErrorPage({ error }: SignInErrorPageProps): react.JSX.Element;
49
+
50
+ export { SIGN_IN_ERROR_CODES, type SignInErrorAction, type SignInErrorActionKind, type SignInErrorCode, SignInErrorPage, type SignInErrorPageProps, type SignInErrorPayload };
@@ -0,0 +1,46 @@
1
+ import { Button } from '@godxjp/ui/general';
2
+ import { ErrorSurface } from '@godxjp/ui/layout';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+
5
+ // src/errors/types.ts
6
+ var SIGN_IN_ERROR_CODES = [
7
+ // Sent by the IdP as `error_reason` on a refused /sso/authorize.
8
+ "organization_membership_missing",
9
+ "organization_unavailable",
10
+ "organization_context_missing",
11
+ "service_entitlement_missing",
12
+ "subscription_inactive",
13
+ "personal_workspace_not_enabled",
14
+ "user_inactive",
15
+ "user_unverified",
16
+ "service_unavailable",
17
+ "employee_onboarding",
18
+ // Raised on the consumer's side.
19
+ "access_denied",
20
+ "callback_invalid",
21
+ "token_exchange_failed",
22
+ "platform_unavailable",
23
+ "session_expired",
24
+ "forbidden",
25
+ "too_many_attempts"
26
+ ];
27
+ function SignInErrorPage({ error }) {
28
+ const { action } = error;
29
+ return /* @__PURE__ */ jsx(
30
+ ErrorSurface,
31
+ {
32
+ mode: "system",
33
+ status: error.status,
34
+ title: error.title,
35
+ description: error.detail === null || error.detail === "" ? error.description : /* @__PURE__ */ jsxs(Fragment, { children: [
36
+ error.description,
37
+ " ",
38
+ /* @__PURE__ */ jsx("span", { "data-slot": "sign-in-error-detail", children: error.detail })
39
+ ] }),
40
+ requestId: error.request_id ?? void 0,
41
+ action: action === null ? null : /* @__PURE__ */ jsx(Button, { asChild: true, "data-action": action.kind, children: /* @__PURE__ */ jsx("a", { href: action.href, children: action.label }) })
42
+ }
43
+ );
44
+ }
45
+
46
+ export { SIGN_IN_ERROR_CODES, SignInErrorPage };
@@ -22,6 +22,18 @@ var ApiError = class extends Error {
22
22
  // src/laravel/api-client.ts
23
23
  var UNREACHABLE = "Could not reach the server. Check your connection and try again.";
24
24
  var redirectingToSignIn = false;
25
+ var liftsLatchOnRestore = false;
26
+ function liftLatchOnRestore() {
27
+ if (liftsLatchOnRestore) {
28
+ return;
29
+ }
30
+ liftsLatchOnRestore = true;
31
+ window.addEventListener("pageshow", (event) => {
32
+ if (event.persisted) {
33
+ redirectingToSignIn = false;
34
+ }
35
+ });
36
+ }
25
37
  function readCookie(name) {
26
38
  const prefix = `${name}=`;
27
39
  const entry = document.cookie.split("; ").find((cookie) => cookie.startsWith(prefix));
@@ -77,6 +89,7 @@ function redirectToSignIn(signInUrl) {
77
89
  return;
78
90
  }
79
91
  redirectingToSignIn = true;
92
+ liftLatchOnRestore();
80
93
  const { pathname, search } = window.location;
81
94
  window.location.assign(signInUrl(`${pathname}${search}`));
82
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "GoDX JavaScript SDK \u2014 shared frontend infrastructure for GoDX products.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -24,6 +24,14 @@
24
24
  "types": "./dist/index.d.ts",
25
25
  "import": "./dist/index.js"
26
26
  },
27
+ "./agent": {
28
+ "types": "./dist/agent/index.d.ts",
29
+ "import": "./dist/agent/index.js"
30
+ },
31
+ "./errors": {
32
+ "types": "./dist/errors/index.d.ts",
33
+ "import": "./dist/errors/index.js"
34
+ },
27
35
  "./laravel": {
28
36
  "types": "./dist/laravel/index.d.ts",
29
37
  "import": "./dist/laravel/index.js"
@@ -41,6 +49,7 @@
41
49
  "test": "vitest run --typecheck"
42
50
  },
43
51
  "devDependencies": {
52
+ "@godxjp/ui": "^33.12.4",
44
53
  "@laravel/echo-react": "^2.5.0",
45
54
  "@types/node": "^22.10.2",
46
55
  "@types/react": "^19.3.0",
@@ -55,10 +64,14 @@
55
64
  "vitest": "^2.1.8"
56
65
  },
57
66
  "peerDependencies": {
67
+ "@godxjp/ui": ">=19.2.0 <34",
58
68
  "@laravel/echo-react": "^2.5.0",
59
69
  "react": "^19.3.0"
60
70
  },
61
71
  "peerDependenciesMeta": {
72
+ "@godxjp/ui": {
73
+ "optional": true
74
+ },
62
75
  "@laravel/echo-react": {
63
76
  "optional": true
64
77
  },