@decidio/sdk 0.2.2 → 0.3.3
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 +189 -12
- package/dist/adapters/_gate.js +1 -1
- package/dist/adapters/langgraph.d.ts +2 -2
- package/dist/adapters/langgraph.js +2 -2
- package/dist/adapters/openai.d.ts +14 -4
- package/dist/adapters/openai.js +3 -2
- package/dist/adapters/temporal.d.ts +2 -2
- package/dist/adapters/temporal.js +7 -2
- package/dist/cli.js +97 -24
- package/dist/errors.js +12 -4
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +13 -7
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -2
- package/dist/inngest.d.ts +10 -2
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +164 -27
- package/dist/resume.js +432 -201
- package/dist/signing.d.ts +8 -1
- package/dist/signing.js +18 -23
- package/openapi.yaml +38 -0
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
One line to put a human-or-policy approval gate in front of any AI-agent action — and get a signed, verifiable Authority Receipt for every decision, with execution evidence accruing on the record.
|
|
4
4
|
|
|
5
|
+
`@decidio/sdk` is the client for Decidio — the system of record for enterprise authority, from OmniTwin Technologies.
|
|
6
|
+
|
|
5
7
|
## The two-minute tour
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -21,15 +23,17 @@ prints the approve command and exits rather than approve its own request (CI can
|
|
|
21
23
|
|
|
22
24
|
## Quickstart — seven steps to your first sealed receipt
|
|
23
25
|
|
|
24
|
-
Under 30 minutes, no help needed. You'll need Node 20.6
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
allowance
|
|
26
|
+
Under 30 minutes, no help needed. You'll need Node 20.6+ and a terminal.
|
|
27
|
+
|
|
28
|
+
**You'll need a Decidio workspace.** → Request one at [decidioai.com](https://decidioai.com/#early-access)
|
|
29
|
+
|
|
30
|
+
The hosted sandbox is synthetic data, safe to experiment in. Note it is ONE SHARED workspace:
|
|
31
|
+
workspace-wide views like `tokens list` or `approvals` show every lab member's agents and
|
|
32
|
+
decisions — the shared-lab boundary working as designed. Production workspaces are per-tenant.
|
|
33
|
+
|
|
34
|
+
Lab workspaces have a **governed-action allowance** (currently 30 actions/hour); hitting it raises
|
|
35
|
+
a typed `DecidioRateLimitError` naming the limit and reset time — nothing is routed or executed —
|
|
36
|
+
and `tokens list` shows your remaining allowance any time.
|
|
33
37
|
|
|
34
38
|
**PowerShell users** — the equivalents for every Bash idiom used below:
|
|
35
39
|
|
|
@@ -144,8 +148,12 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
|
|
|
144
148
|
record verify panel — or simply read it off the `receipt` command's output, which prints your
|
|
145
149
|
workspace's issuer DID from the authenticated response so you never have to hunt for it).
|
|
146
150
|
|
|
147
|
-
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's
|
|
148
|
-
sealed by your workspace's issuer and
|
|
151
|
+
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
|
|
152
|
+
sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
|
|
153
|
+
signature covers the record's canonical form (JCS), so reformatting the file — reindenting it,
|
|
154
|
+
minifying it, reordering keys — still verifies, and should, because none of that alters a claim.
|
|
155
|
+
If you need byte-for-byte custody, hash the file and keep that hash yourself; this proves the
|
|
156
|
+
content, which is what an auditor is asking about. It does **not** prove the approved
|
|
149
157
|
action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
|
|
150
158
|
receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
|
|
151
159
|
receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
|
|
@@ -382,10 +390,30 @@ for (const item of await guard.resume.recover()) {
|
|
|
382
390
|
console.log(item.state, item.decisionId, item.guidance);
|
|
383
391
|
}
|
|
384
392
|
// "executed_unreported" → guard.resume.reconfirm(id) re-sends the saved report (never re-runs)
|
|
385
|
-
// "indeterminate" →
|
|
393
|
+
// "indeterminate" → no outcome was recorded; check downstream, then reconcile. Never blind-retry
|
|
386
394
|
// "parked" → still waiting on a human; nothing has executed
|
|
387
395
|
```
|
|
388
396
|
|
|
397
|
+
Two things `reconcile()` will refuse, because both would undo an authority decision rather than
|
|
398
|
+
record one. **It refuses by returning, not by throwing** — check `reportRecorded`, and read
|
|
399
|
+
`diagnostic` to see why:
|
|
400
|
+
|
|
401
|
+
```js
|
|
402
|
+
// A denial is final. There is no outcome to record — nothing ran — so neither outcome is accepted.
|
|
403
|
+
const r = await guard.resume.reconcile(deniedId, { outcome: "not_committed" });
|
|
404
|
+
r.reportRecorded; // false — r.diagnostic says the decision was DENIED
|
|
405
|
+
|
|
406
|
+
// A decision that is still `invoking` may be running the action at this moment. Releasing it would
|
|
407
|
+
// let a second worker run the same approved action, so it takes an explicit assertion — from you,
|
|
408
|
+
// after you have checked. Nothing here can see another machine's process table, so this is your
|
|
409
|
+
// judgement, and it is stamped into the ledger as such.
|
|
410
|
+
await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…" }); // refused while invoking
|
|
411
|
+
await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…", workerStopped: true }); // released
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`indeterminate` needs no such flag: the handler has already returned or thrown, so nothing is in
|
|
415
|
+
flight — only the downstream result is unknown.
|
|
416
|
+
|
|
389
417
|
The report a worker sends is written to the pending store's execution ledger *before* the first
|
|
390
418
|
send attempt, so a lost confirmation is recoverable; and because that record holds no handler
|
|
391
419
|
binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
|
|
@@ -420,6 +448,135 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
|
|
|
420
448
|
for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
|
|
421
449
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
422
450
|
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
## The durable store is a revision chain (0.3.2)
|
|
455
|
+
|
|
456
|
+
`FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
|
|
457
|
+
`<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
|
|
458
|
+
head, validates against exactly that record, writes the successor to a temp file, and publishes it
|
|
459
|
+
with `link()` to the next revision number. `link()` fails if that name already exists, so **exactly
|
|
460
|
+
one writer wins each revision**, with no lock, no timeout and no daemon — on any volume that
|
|
461
|
+
supports hard links (NTFS, ext4, APFS, XFS; not FAT/exFAT, and not every network or FUSE mount —
|
|
462
|
+
on those every write fails closed rather than silently degrading). A loser re-reads the new head
|
|
463
|
+
and validates again — against what actually happened, not a snapshot. **No revision file is ever
|
|
464
|
+
deleted:** a name that could be reused is a race that could be lost, so the chain is genuinely
|
|
465
|
+
append-only and a decision's whole history stays readable in place.
|
|
466
|
+
|
|
467
|
+
This replaced the 0.2/0.3 one-marker-per-state layout after an external retest showed why it had to:
|
|
468
|
+
that layout made a transition in three separate steps (read the markers, write the new one, sweep
|
|
469
|
+
the weaker ones), so a process holding `claimed_preinvoke` could validate its snapshot, lose to a
|
|
470
|
+
second process that durably wrote *and returned* a denial, then write `invoking` anyway — and its
|
|
471
|
+
sweep deleted the tombstone. The SDK reported a denial as final and ran the denied action. Every
|
|
472
|
+
*sequential* schedule was safe; the window was inside one transition. The chain has no such window:
|
|
473
|
+
nothing ever deletes the head, and the rule that refuses a denied decision now runs against the
|
|
474
|
+
denial that just landed.
|
|
475
|
+
|
|
476
|
+
**Upgrading.** A directory in the older layout is migrated once, at the first construction of a
|
|
477
|
+
0.3.2 store: each decision's markers become the head of a fresh chain and the markers are removed.
|
|
478
|
+
Corrupt markers are left exactly as found and the decision reads as unreadable until a person
|
|
479
|
+
inspects it. **Stop every older worker that shares the directory BEFORE the first 0.3.2 process
|
|
480
|
+
constructs its store, then upgrade them together.** The one-time migration reads the markers it
|
|
481
|
+
finds and removes exactly those; an older SDK still writing leaves a marker beside a chain, and
|
|
482
|
+
that decision is then *unreadable* by design — neither reading can be trusted over the other, so
|
|
483
|
+
the store refuses to pick one.
|
|
484
|
+
|
|
485
|
+
**Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
|
|
486
|
+
against concurrent callers. The shipped file store did not honor that until now; a custom store must.
|
|
487
|
+
The deterministic two-process schedules that proved the defect live in
|
|
488
|
+
`packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
|
|
489
|
+
is paused between validating and publishing; two conflicting settlements from one head; and a denier
|
|
490
|
+
paused while the approver publishes four revisions past it. A store that cannot pass them is not
|
|
491
|
+
atomic, whatever its methods say.
|
|
492
|
+
|
|
493
|
+
## Reading a receipt
|
|
494
|
+
|
|
495
|
+
The cryptography is only half the evidence. These are the fields that an outside reader has
|
|
496
|
+
actually misread on a first run, and what each one means.
|
|
497
|
+
|
|
498
|
+
**`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
|
|
499
|
+
nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
|
|
500
|
+
that list is empty — nobody was nominated, because the request had not happened yet. The person who
|
|
501
|
+
actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
|
|
502
|
+
`deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
|
|
503
|
+
played. The verifier prints this explanation whenever the pair appears.
|
|
504
|
+
|
|
505
|
+
**`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
|
|
506
|
+
three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
|
|
507
|
+
`auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
|
|
508
|
+
to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
|
|
509
|
+
request was compared to and did not clear, not a limit that was applied to it. Read
|
|
510
|
+
`requested_amount` as the ask, the other two as ceilings.
|
|
511
|
+
|
|
512
|
+
**`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
|
|
513
|
+
auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
|
|
514
|
+
non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
|
|
515
|
+
disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
|
|
516
|
+
|
|
517
|
+
**`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
|
|
518
|
+
`urn:decidio:sealed-payload:1:sha256:<hash>` — so it *identifies* the exact schema the record was
|
|
519
|
+
sealed under and pins it against substitution. It does not locate a document, and nothing will
|
|
520
|
+
resolve it. (Shipping the schema alongside the verifier so you can check the hash yourself is on
|
|
521
|
+
the roadmap; today the URN is an identifier only, and this note exists so it does not look broken.)
|
|
522
|
+
|
|
523
|
+
**`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
|
|
524
|
+
sealed after a specific predecessor, but a single-file verification cannot check that predecessor
|
|
525
|
+
exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
|
|
526
|
+
matters.
|
|
527
|
+
|
|
528
|
+
**Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
|
|
529
|
+
DID arrive in the same authenticated response — so that first check trusts the connection, not the
|
|
530
|
+
key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
|
|
531
|
+
every later verification is checked against a key you chose rather than one you were handed. The
|
|
532
|
+
verifier says so on every unpinned run.
|
|
533
|
+
|
|
534
|
+
## Upgrading from 0.2.x to 0.3.0
|
|
535
|
+
|
|
536
|
+
0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
|
|
537
|
+
none of it is left to be discovered at runtime.
|
|
538
|
+
|
|
539
|
+
**If you wrote a custom `PendingStore`, it will fail to construct.** `markDiscarded(decisionId,
|
|
540
|
+
verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a denied decision
|
|
541
|
+
permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
|
|
542
|
+
this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
|
|
543
|
+
owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
|
|
544
|
+
payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
|
|
545
|
+
`packages/conformance/vectors/ownership.json` is the corpus your store should pass.
|
|
546
|
+
|
|
547
|
+
**`recover()` now throws instead of returning `[]`** when the ledger cannot be read
|
|
548
|
+
(`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
|
|
549
|
+
to a caller and mean opposite things, so a script reading `if (!(await recover()).length)` treated
|
|
550
|
+
a permissions change as proof that nothing was outstanding. Wrap the call if you poll it.
|
|
551
|
+
|
|
552
|
+
**`reconcile()` refuses two things it used to allow.** A DENIED decision cannot be reconciled on
|
|
553
|
+
either outcome, and releasing a decision that is still `invoking` takes an explicit
|
|
554
|
+
`workerStopped: true` — that is the one state where the handler may be running right now, and
|
|
555
|
+
releasing it hands one approved action to two live workers. It refuses by RETURNING, not throwing:
|
|
556
|
+
check `reportRecorded` and read `diagnostic`.
|
|
557
|
+
|
|
558
|
+
**The adapters return a different shape.** `decidioGate` (LangGraph, Temporal) returns
|
|
559
|
+
`ProtectedExecution` — `{ value, decisionId, confirmation }` — instead of the bare value.
|
|
560
|
+
`decidioGateStep` (Inngest) renames `result` to `value` and carries `confirmation`.
|
|
561
|
+
`gateInterruptions` is now `authorizeInterruptions` and returns `confirmationSupported: false`
|
|
562
|
+
(the old name remains as a deprecated alias). Read `.value` where you previously read the result.
|
|
563
|
+
|
|
564
|
+
**The transport rule refuses URLs it used to accept.** A gate URL must now be absolute and either
|
|
565
|
+
https or an exact loopback host, whatever credentials the config holds. A relative or scheme-less
|
|
566
|
+
`apiUrl` throws at startup — it used to pass on the reasoning that the shipped transport rejects
|
|
567
|
+
it, which says nothing about a custom `fetchImpl`.
|
|
568
|
+
|
|
569
|
+
**Decision ids are shape-checked.** Letters, digits, dot, dash and underscore, starting with a
|
|
570
|
+
letter or digit, at most 128 characters, never trimmed. The server enforces the same grammar (it
|
|
571
|
+
had none before 0.3.0, which is why the SDK was the stricter of the two). Ids Decidio issues are
|
|
572
|
+
well inside it.
|
|
573
|
+
|
|
574
|
+
**`/agent/confirm` can answer 409.** `TERMINALLY_DENIED` means a human refused the decision and
|
|
575
|
+
your action appears to have run anyway; `NOT_AUTHORIZED` means there is no approved, sealed
|
|
576
|
+
decision for evidence to attach to. Neither is a report failure and neither is retryable — the SDK
|
|
577
|
+
raises `DecidioHttpError` with advice that says so, rather than the "only its report failed" note
|
|
578
|
+
that fits every other confirm error.
|
|
579
|
+
|
|
423
580
|
## Engine adapters (BETA) — and Python too
|
|
424
581
|
|
|
425
582
|
- **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
|
|
@@ -430,6 +587,17 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
430
587
|
runtimes. Use the core path for production until that changes. Each adapter is bound through its
|
|
431
588
|
own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
|
|
432
589
|
The worker/webhook resume is the universal path and is not beta.
|
|
590
|
+
- **One result shape on every door (0.3.0, breaking).** `decidioGate` (LangGraph, Temporal) and
|
|
591
|
+
`decidioGateStep` (Inngest) now return `{ value, decisionId, confirmation }` — the same
|
|
592
|
+
`ProtectedExecution` the core path returns. They used to hand back the bare value and discard what
|
|
593
|
+
Decidio recorded, so "it ran and was recorded" and "it ran and Decidio does not know" were the
|
|
594
|
+
same return value to a caller. Inngest keeps its `status`, because its durable path can end in
|
|
595
|
+
`rejected` or `blocked` without ever calling your action; its value field is `value`, not
|
|
596
|
+
`result`.
|
|
597
|
+
The OpenAI door is the exception and now says so: it was renamed `gateInterruptions` →
|
|
598
|
+
`authorizeInterruptions` and returns `confirmationSupported: false`. The OpenAI runtime executes
|
|
599
|
+
the tool itself, so this SDK never sees the return value and has nothing to attest — and an
|
|
600
|
+
ABSENT confirmation must not look like a failed one. The old name remains as a deprecated alias.
|
|
433
601
|
- **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
|
|
434
602
|
|
|
435
603
|
## CLI
|
|
@@ -495,3 +663,12 @@ your queue.
|
|
|
495
663
|
- **Confirmation integrity:** the wrapper runs in your process, so it captures the genuine API response — a materially stronger signal than an agent self-report, but it trusts the host. For high-assurance, Decidio can *independently* read the source back (`independently_reconciled`) where it holds a read connection.
|
|
496
664
|
- **Zero-code alternative:** an agent that speaks MCP (e.g. Claude on the web) can reach the same gate via Decidio's `request_approval` MCP tool with no code at all.
|
|
497
665
|
- **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
|
|
666
|
+
|
|
667
|
+
## Using this for real?
|
|
668
|
+
|
|
669
|
+
The hosted sandbox is synthetic and shared. Production workspaces are per-tenant, with your own
|
|
670
|
+
issuer key and receipts that verify offline against it.
|
|
671
|
+
|
|
672
|
+
[decidioai.com](https://decidioai.com) · [Developer docs](https://decidioai.com/developers/) · [Request access](https://decidioai.com/#early-access)
|
|
673
|
+
|
|
674
|
+
Built by OmniTwin Technologies.
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -14,7 +14,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
14
14
|
return a;
|
|
15
15
|
}
|
|
16
16
|
export async function gateAuthorize(config, ctx) {
|
|
17
|
-
canonicalize(ctx, "describe(...)");
|
|
17
|
+
ctx = canonicalize(ctx, "describe(...)");
|
|
18
18
|
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
19
19
|
const f = config.fetchImpl ?? fetch;
|
|
20
20
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "@langchain/langgraph";
|
|
2
|
-
import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
2
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
|
|
5
5
|
* `interrupt` finds the running graph itself (no handle needed), so the gate drops into
|
|
@@ -13,7 +13,7 @@ import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
|
13
13
|
/** Gate `run()` behind Decidio inside a LangGraph node. */
|
|
14
14
|
export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
|
|
15
15
|
evidence?: (r: R) => unknown;
|
|
16
|
-
}): Promise<R
|
|
16
|
+
}): Promise<ProtectedExecution<R>>;
|
|
17
17
|
/** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
|
|
18
18
|
* maps decisionId → thread_id and passes this to graph.invoke/stream. */
|
|
19
19
|
export declare function decidioResumeCommand(body: {
|
|
@@ -10,8 +10,8 @@ export async function decidioGate(config, ctx, run, opts) {
|
|
|
10
10
|
assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
|
|
11
11
|
}
|
|
12
12
|
const result = await run();
|
|
13
|
-
await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
14
|
-
return result;
|
|
13
|
+
const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
14
|
+
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
15
15
|
}
|
|
16
16
|
export function decidioResumeCommand(body) {
|
|
17
17
|
return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
|
|
@@ -34,13 +34,23 @@ export interface Pending {
|
|
|
34
34
|
item: ToolApprovalItem;
|
|
35
35
|
decisionId: string;
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
37
|
+
/** THIS DOOR AUTHORIZES; IT DOES NOT CONFIRM. Named `gateInterruptions` it looked like the other
|
|
38
|
+
* `decidioGate*` functions, which all run the action and report what happened. This one cannot:
|
|
39
|
+
* the OpenAI Agents runtime executes the tool itself, so the SDK never sees the return value and
|
|
40
|
+
* has nothing to attest. Every other adapter now returns a `confirmation`; this one deliberately
|
|
41
|
+
* returns `confirmationSupported: false` instead of an empty one, because an absent confirmation
|
|
42
|
+
* and a failed confirmation must not look alike.
|
|
43
|
+
*
|
|
44
|
+
* proceed → state.approve(item); block → state.reject(item); route → pending (a human decides via
|
|
45
|
+
* Decidio; the host parks state.toString() + resumes via applyResume). */
|
|
46
|
+
export declare function authorizeInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
|
|
41
47
|
resolved: Resolved[];
|
|
42
48
|
pending: Pending[];
|
|
49
|
+
confirmationSupported: false;
|
|
43
50
|
}>;
|
|
51
|
+
/** @deprecated Renamed to `authorizeInterruptions` in 0.3.0 — the old name read like the other
|
|
52
|
+
* `decidioGate*` doors, which execute and confirm. This one only ever authorized. */
|
|
53
|
+
export declare const gateInterruptions: typeof authorizeInterruptions;
|
|
44
54
|
/** Apply a Decidio verdict to a resumed RunState (after RunState.fromString).
|
|
45
55
|
*
|
|
46
56
|
* VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
|
package/dist/adapters/openai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { gateAuthorize, checkEngineVersion } from "./_gate.js";
|
|
2
2
|
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
export async function
|
|
3
|
+
export async function authorizeInterruptions(config, state, describe) {
|
|
4
4
|
const resolved = [];
|
|
5
5
|
const pending = [];
|
|
6
6
|
for (const item of state.getInterruptions()) {
|
|
@@ -18,8 +18,9 @@ export async function gateInterruptions(config, state, describe) {
|
|
|
18
18
|
else
|
|
19
19
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
|
|
20
20
|
}
|
|
21
|
-
return { resolved, pending };
|
|
21
|
+
return { resolved, pending, confirmationSupported: false };
|
|
22
22
|
}
|
|
23
|
+
export const gateInterruptions = authorizeInterruptions;
|
|
23
24
|
export function applyResume(state, item, verdict) {
|
|
24
25
|
if (verdict === "approved" || verdict === "auto_approved") {
|
|
25
26
|
state.approve(item);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
2
2
|
import { type GateAuth } from "./_gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
@@ -32,4 +32,4 @@ export interface DecidioGateOpts<R = unknown> {
|
|
|
32
32
|
export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, verdicts: Map<string, {
|
|
33
33
|
verdict: string;
|
|
34
34
|
reason?: string;
|
|
35
|
-
}>, opts?: DecidioGateOpts<R>): Promise<R
|
|
35
|
+
}>, opts?: DecidioGateOpts<R>): Promise<ProtectedExecution<R>>;
|
|
@@ -21,18 +21,23 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
21
21
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
|
|
22
22
|
}
|
|
23
23
|
const result = await run();
|
|
24
|
+
let confirmation;
|
|
24
25
|
if (opts.confirm) {
|
|
25
26
|
if (opts.evidence)
|
|
26
27
|
console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
|
|
27
28
|
await opts.confirm(auth.decisionId, result);
|
|
29
|
+
confirmation = {
|
|
30
|
+
reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
|
|
31
|
+
diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
|
|
32
|
+
};
|
|
28
33
|
if (!confirmWarned) {
|
|
29
34
|
confirmWarned = true;
|
|
30
35
|
console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
else {
|
|
34
|
-
await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
39
|
+
confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
35
40
|
}
|
|
36
|
-
return result;
|
|
41
|
+
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
37
42
|
}
|
|
38
43
|
checkEngineVersion("temporal");
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import { userInfo } from "node:os";
|
|
5
4
|
import { createServer } from "node:http";
|
|
6
5
|
import { execFileSync } from "node:child_process";
|
|
7
6
|
import { createInterface } from "node:readline";
|
|
8
7
|
import { createRequire } from "node:module";
|
|
9
8
|
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
9
|
+
import { hardenFile } from "./resume.js";
|
|
10
10
|
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
11
11
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
12
|
const VERSION = (() => {
|
|
@@ -39,6 +39,14 @@ function readEnvFile() {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
|
|
42
|
+
if (!OFFLINE_CMD) {
|
|
43
|
+
readEnvFile();
|
|
44
|
+
if (envUnreadable) {
|
|
45
|
+
console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
|
|
46
|
+
console.error(` Treating it as empty would risk overwriting an agent key that cannot be recovered. Fix the file's permissions (or move it aside) and run the command again. Nothing was changed.`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
if (!OFFLINE_CMD)
|
|
43
51
|
for (const [k, v] of Object.entries(readEnvFile())) {
|
|
44
52
|
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
@@ -106,22 +114,22 @@ function writeSecretFile(path, body) {
|
|
|
106
114
|
chmodSync(path, 0o600);
|
|
107
115
|
}
|
|
108
116
|
catch { }
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
|
|
115
|
-
}
|
|
117
|
+
const why = hardenFile(path);
|
|
118
|
+
if (why) {
|
|
119
|
+
console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
122
|
+
async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
123
|
+
err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
|
|
124
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
125
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
126
|
+
err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
|
|
127
|
+
return true;
|
|
124
128
|
}
|
|
129
|
+
err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
|
|
130
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
131
|
+
err(` npx @decidio/sdk tokens list --mine`);
|
|
132
|
+
return false;
|
|
125
133
|
}
|
|
126
134
|
function upsertEnv(updates) {
|
|
127
135
|
const remaining = { ...updates };
|
|
@@ -345,8 +353,19 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
345
353
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
346
354
|
if (tokenJti)
|
|
347
355
|
envOut.DECIDIO_TOKEN_JTI = tokenJti;
|
|
348
|
-
|
|
349
|
-
|
|
356
|
+
try {
|
|
357
|
+
prepareEnvForWrite();
|
|
358
|
+
upsertEnv(envOut);
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
if (agentToken && tokenJti)
|
|
362
|
+
await revokeFreshMint(tokenJti, mintBearer, e, ENV_PATH);
|
|
363
|
+
else if (agentToken)
|
|
364
|
+
err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). The token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
365
|
+
else
|
|
366
|
+
err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). No token was minted this run.`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
350
369
|
for (const [k, v] of Object.entries(envOut))
|
|
351
370
|
process.env[k] = v;
|
|
352
371
|
log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
@@ -601,10 +620,13 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
601
620
|
}
|
|
602
621
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
603
622
|
if (issuer) {
|
|
623
|
+
log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
|
|
624
|
+
log(``);
|
|
625
|
+
log(`${issuer}`);
|
|
626
|
+
log(``);
|
|
627
|
+
log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
|
|
628
|
+
log(` which your terminal may wrap):`);
|
|
604
629
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
605
|
-
log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
|
|
606
|
-
log(` response above — record it once, then pin it forever):`);
|
|
607
|
-
log(` ${issuer}`);
|
|
608
630
|
log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
|
|
609
631
|
log(` issued it; it says so in its output.`);
|
|
610
632
|
}
|
|
@@ -625,6 +647,10 @@ function cmdVerify(argsIn) {
|
|
|
625
647
|
err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
|
|
626
648
|
return 2;
|
|
627
649
|
}
|
|
650
|
+
if (expectedIssuer !== undefined && expectedIssuer !== v) {
|
|
651
|
+
err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
|
|
652
|
+
return 2;
|
|
653
|
+
}
|
|
628
654
|
expectedIssuer = v;
|
|
629
655
|
i++;
|
|
630
656
|
}
|
|
@@ -634,6 +660,10 @@ function cmdVerify(argsIn) {
|
|
|
634
660
|
err("✘ --issuer needs a did:key value.");
|
|
635
661
|
return 2;
|
|
636
662
|
}
|
|
663
|
+
if (expectedIssuer !== undefined && expectedIssuer !== v) {
|
|
664
|
+
err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
|
|
665
|
+
return 2;
|
|
666
|
+
}
|
|
637
667
|
expectedIssuer = v;
|
|
638
668
|
}
|
|
639
669
|
else if (!a.startsWith("-")) {
|
|
@@ -715,24 +745,49 @@ async function cmdQuickstart(opts = {}) {
|
|
|
715
745
|
const prev = readFileSync(envFile, "utf8");
|
|
716
746
|
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
717
747
|
const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
|
|
748
|
+
const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
718
749
|
if (prevId && prevId !== agentId) {
|
|
719
750
|
if (prevJti) {
|
|
720
751
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
721
752
|
if (rv.status >= 200 && rv.status < 300)
|
|
722
753
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
723
|
-
else
|
|
724
|
-
|
|
754
|
+
else {
|
|
755
|
+
carried.add(prevJti);
|
|
756
|
+
log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
|
|
757
|
+
}
|
|
725
758
|
}
|
|
726
759
|
else {
|
|
727
760
|
log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
|
|
728
761
|
}
|
|
729
762
|
}
|
|
763
|
+
if (carried.size) {
|
|
764
|
+
envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...carried].join(",");
|
|
765
|
+
log(` • ${carried.size} orphaned token id(s) from earlier replacements carried forward — \`quickstart cleanup\` will retry them.`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
if (mint.body?.jti)
|
|
773
|
+
await revokeFreshMint(mint.body.jti, session, e, envFile);
|
|
774
|
+
else
|
|
775
|
+
err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
776
|
+
return 1;
|
|
730
777
|
}
|
|
731
|
-
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
732
778
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
733
779
|
}
|
|
734
780
|
else {
|
|
735
|
-
|
|
781
|
+
try {
|
|
782
|
+
upsertEnv(envOut);
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
if (mint.body?.jti)
|
|
786
|
+
await revokeFreshMint(mint.body.jti, session, e, envFile);
|
|
787
|
+
else
|
|
788
|
+
err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
789
|
+
return 1;
|
|
790
|
+
}
|
|
736
791
|
}
|
|
737
792
|
const failTour = async (code) => {
|
|
738
793
|
log("");
|
|
@@ -859,6 +914,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
859
914
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
860
915
|
log("");
|
|
861
916
|
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
|
|
917
|
+
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
862
918
|
let doCleanup = false;
|
|
863
919
|
if (interactive) {
|
|
864
920
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -884,6 +940,23 @@ async function quickstartCleanup(session, envFile) {
|
|
|
884
940
|
}
|
|
885
941
|
return out;
|
|
886
942
|
})();
|
|
943
|
+
const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
944
|
+
const stillOrphaned = [];
|
|
945
|
+
for (const jti of orphans) {
|
|
946
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
|
|
947
|
+
if (rv.status >= 200 && rv.status < 300)
|
|
948
|
+
log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
|
|
949
|
+
else {
|
|
950
|
+
stillOrphaned.push(jti);
|
|
951
|
+
err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (stillOrphaned.length) {
|
|
955
|
+
err(`✘ ${stillOrphaned.length} orphaned token(s) are STILL LIVE and could not be revoked. Nothing was deleted; ${envFile} keeps their ids so you can retry:`);
|
|
956
|
+
for (const jti of stillOrphaned)
|
|
957
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
958
|
+
return 1;
|
|
959
|
+
}
|
|
887
960
|
if (env.DECIDIO_TOKEN_JTI) {
|
|
888
961
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
|
|
889
962
|
if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
|
|
@@ -906,7 +979,7 @@ async function quickstartCleanup(session, envFile) {
|
|
|
906
979
|
return 1;
|
|
907
980
|
}
|
|
908
981
|
if (existsSync(envFile)) {
|
|
909
|
-
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
|
|
982
|
+
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
|
|
910
983
|
const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
|
|
911
984
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
912
985
|
return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const RETRYABLE_STATUS = (status) => status === 408 || status === 429 || (status >= 500 && status !== 501);
|
|
2
|
-
function sideEffectNote(endpoint) {
|
|
2
|
+
function sideEffectNote(endpoint, code) {
|
|
3
3
|
if (endpoint.startsWith("/agent/confirm")) {
|
|
4
|
+
if (code === "TERMINALLY_DENIED") {
|
|
5
|
+
return " A human DENIED this decision, and your action appears to have run anyway. Decidio recorded the refusal and will NOT attach execution evidence to a denied decision. Do NOT re-run it — investigate why the action executed without an approval.";
|
|
6
|
+
}
|
|
7
|
+
if (code === "NOT_AUTHORIZED") {
|
|
8
|
+
return " This decision is not an approved, sealed decision, so no execution evidence can attach to it yet. If your action already ran, it ran WITHOUT authority — do NOT re-run it; investigate. If you are confirming early, wait for the approval and confirm then.";
|
|
9
|
+
}
|
|
4
10
|
return " Your action ALREADY RAN — only its report failed. Do NOT re-run it.";
|
|
5
11
|
}
|
|
6
12
|
if (endpoint.startsWith("/agent/status")) {
|
|
@@ -22,7 +28,7 @@ export class DecidioHttpError extends Error {
|
|
|
22
28
|
: retryable
|
|
23
29
|
? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
|
|
24
30
|
: ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
|
|
25
|
-
sideEffectNote(endpoint));
|
|
31
|
+
sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
|
|
26
32
|
this.status = status;
|
|
27
33
|
this.endpoint = endpoint;
|
|
28
34
|
this.body = body;
|
|
@@ -44,7 +50,9 @@ export function decisionIdProblem(raw) {
|
|
|
44
50
|
if (cp < 0x20 || cp === 0x7f)
|
|
45
51
|
return "it contains control characters";
|
|
46
52
|
}
|
|
47
|
-
if (raw.length >
|
|
48
|
-
return `it is ${raw.length} characters long (limit
|
|
53
|
+
if (raw.length > 128)
|
|
54
|
+
return `it is ${raw.length} characters long (limit 128)`;
|
|
55
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(raw))
|
|
56
|
+
return "it is not letters, digits, dot, dash or underscore starting with a letter or digit (an opaque id needs nothing else, and every other character is a way to make two different strings look like the same decision)";
|
|
49
57
|
return null;
|
|
50
58
|
}
|