@fungi.computer/ear-tag 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # Ear Tag (`@fungi.computer/ear-tag`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ear Tag is an independent, modular key server for TypeScript hosts. Its base
4
+ entrypoint creates cryptographically random bearer keys and SHA-256 digests, so
5
+ a host can issue a secret like an invite code or an API key without inventing
6
+ its own random-string and hashing logic. `./grants` builds a typed
7
+ capability-grant vocabulary, and `./keys` runs a tenant-scoped key store on top
8
+ of it: issuance, verification, compare-and-swap grant edits, revocation,
9
+ optional expiry, owner suspension and a per-key rate limit. The owner decides
10
+ scope, storage and policy; Ear Tag owns the mechanics.
11
+
12
+ ## Install or set up
13
+
14
+ ```sh
15
+ npm install @fungi.computer/ear-tag
16
+ ```
17
+
18
+ The package ships ESM only and depends on [Cairn](../cairn/README.md) for
19
+ identifiers and random strings, and on `zod` for its schemas. Hashing uses the
20
+ global Web Crypto API (`crypto.subtle.digest`), available in Node 19+ and in
21
+ Workers runtimes. It has four entrypoints: `@fungi.computer/ear-tag` (the crypto
22
+ primitives), `@fungi.computer/ear-tag/grants` (the grant vocabulary),
23
+ `@fungi.computer/ear-tag/keys` (the key store, free of any Node-specific type)
24
+ and `@fungi.computer/ear-tag/keys/node` (the `node:sqlite` adapter, so a Worker
25
+ consumer of `./keys` never needs `@types/node` to typecheck).
26
+
27
+ ## Quick start
28
+
29
+ This helper creates a prefixed key, stores its digest, then hands the plaintext
30
+ to the host's authorized delivery function. The host supplies both operations;
31
+ neither the key nor the digest is logged:
32
+
33
+ ```ts
34
+ import { generateKeyAndHash } from "@fungi.computer/ear-tag";
35
+
36
+ export async function issueInvite(
37
+ storeDigest: (digest: string) => Promise<void>,
38
+ deliverKey: (key: string) => Promise<void>,
39
+ ): Promise<void> {
40
+ const { key, digest } = await generateKeyAndHash({ prefix: "invite" });
41
+ await storeDigest(digest);
42
+ await deliverKey(key);
43
+ }
44
+ ```
45
+
46
+ The existing tests verify that a generated digest matches the plaintext key in
47
+ [`test/crypto.test.ts`](test/crypto.test.ts).
48
+
49
+ ## Core concepts
50
+
51
+ `generateKey(options)` returns a random bearer string. An optional prefix adds
52
+ `<prefix>_` before the random portion. `length` controls the random portion's
53
+ length. `hashKey(key)` returns its SHA-256 digest as lowercase hexadecimal.
54
+ `generateKeyAndHash(options)` creates both values and returns `{ key, digest }`.
55
+
56
+ ## Grants and the key store
57
+
58
+ `./grants` declares one host's capability vocabulary and returns the schemas and
59
+ operations closed over it:
60
+
61
+ ```ts
62
+ import { defineGrants } from "@fungi.computer/ear-tag/grants";
63
+
64
+ const vocabulary = defineGrants({
65
+ capabilities: ["files.read", "files.write"] as const,
66
+ });
67
+ ```
68
+
69
+ `./keys` runs a tenant-scoped key store over that vocabulary. `sql` is the
70
+ `exec(statement, ...bindings) -> { toArray() }` shape Cloudflare Durable Object
71
+ SQLite already provides; `./keys/node`'s `fromNodeSqlite` adapts a `node:sqlite`
72
+ database for hosts that run outside a Durable Object:
73
+
74
+ ```ts
75
+ import { createKeyStore } from "@fungi.computer/ear-tag/keys";
76
+ import { fromNodeSqlite } from "@fungi.computer/ear-tag/keys/node";
77
+ import { DatabaseSync } from "node:sqlite";
78
+
79
+ const store = createKeyStore({
80
+ sql: fromNodeSqlite(new DatabaseSync(":memory:")),
81
+ commit: (body) => Promise.resolve(body()),
82
+ vocabulary,
83
+ prefix: "demo",
84
+ authorizeIssuer: async () => ({ kind: "unrestricted" }),
85
+ });
86
+
87
+ const issued = await store.issue("owner-1", {
88
+ name: "ci bot",
89
+ grants: [{ capability: "files.read", targets: { kind: "all" } }],
90
+ });
91
+ ```
92
+
93
+ Every operation returns a `KeyOutcome`: `{ kind: "ok", value }` or
94
+ `{ kind: "error", error: { code, retryAfterSeconds? } }`, with `code` one of
95
+ `invalid` (the caller's own request failed validation), `denied` (well-formed
96
+ but refused on its merits), `conflict`, `expired`, `rate_limited` or
97
+ `unavailable` (the store's own state could not be read back; always logged). One
98
+ store instance is one tenant; namespacing between tenants is the host's own
99
+ table or storage boundary.
100
+
101
+ `authorizeIssuer`'s `"bounded"` only narrows which grants an actor may assign
102
+ through `issue` or `updateGrants`. It does not narrow which keys that actor may
103
+ `list`, `revoke` or edit: any actor it resolves to a non-null authority for may
104
+ manage every key in the tenant, including keys another actor issued. This
105
+ matches Hub's own `authorizeManage`. `implies` is one level deep, not
106
+ transitive.
107
+
108
+ ## API reference
109
+
110
+ See the generated API reference for [the crypto primitives](docs/api/index.md),
111
+ [`./grants`](docs/api/grants.md), [`./keys`](docs/api/keys.md) and
112
+ [`./keys/node`](docs/api/keys-node.md), or start from
113
+ [the index](docs/api/README.md).
114
+
115
+ ## Examples and checks
116
+
117
+ The crypto tests cover hashing, prefixed generation, and the key/digest pair.
118
+ The grants and key-store tests cover issuance, verification, compare-and-swap
119
+ grant edits and replay, authority-change fencing, a stale captured principal,
120
+ revocation, optional expiry, owner suspension, the per-key rate limit, bounded
121
+ sub-issuance and bounded grant edits, and `admitUse` on an expired or suspended
122
+ key, run through the exact same assertions against two backends: a real
123
+ Cloudflare Durable Object (`pnpm run test:cloudflare`, real `state.storage.sql`
124
+ and `transactionSync`, not an adapter standing in for them) and the package's
125
+ own `node:sqlite` adapter (`pnpm run test:node`). Run everything with:
126
+
127
+ ```sh
128
+ pnpm --filter @fungi.computer/ear-tag test
129
+ ```
130
+
131
+ ## Limits and errors
132
+
133
+ A bearer key is a secret: anyone who obtains it can present it to the system
134
+ that accepts it. Do not log or persist plaintext keys. Retain the digest and
135
+ give the plaintext only to its intended recipient.
136
+
137
+ `hashKey` requires Web Crypto's `crypto.subtle.digest`. Key length follows
138
+ Cairn's raw `nanoid` sizing behavior. Callers should supply a bounded
139
+ nonnegative integer rather than untrusted sizing input.
140
+
141
+ The key store's rate limit is counted in memory, not persisted; it resets if the
142
+ host recreates the store (for example a Durable Object waking from eviction). A
143
+ `KeyPrincipal` returned by `verify` is a plain, unsealed data object: `current`
144
+ and `admitUse` accept one back to resume a session cheaply, but they re-read the
145
+ live key row and its grant version on every call, so a fabricated or stale
146
+ principal is refused rather than trusted.
147
+
148
+ ## Contribute and license
149
+
150
+ Ear Tag is MIT licensed. See [`LICENSE`](LICENSE).
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ /** How a grant's capability reaches resources. */
3
+ export type GrantTargets = Readonly<{
4
+ /** The capability reaches every resource, now and later. */
5
+ kind: "all";
6
+ }> | Readonly<{
7
+ /** The capability reaches only these resources; a later resource never joins implicitly. */
8
+ kind: "selected";
9
+ /** Bounded, explicitly chosen resource identifiers this grant covers. */
10
+ ids: readonly string[];
11
+ }>;
12
+ /** One capability bound to the resources it may act on. */
13
+ export type Grant<Capability extends string = string> = Readonly<{
14
+ /** Capability name drawn from the host's declared vocabulary. */
15
+ capability: Capability;
16
+ /** Resources this grant reaches. */
17
+ targets: GrantTargets;
18
+ }>;
19
+ /** The bounded authority one key carries: 1 to 64 grants. */
20
+ export type Scope<Capability extends string = string> = Readonly<{
21
+ /** Every grant the key carries. */
22
+ grants: readonly Grant<Capability>[];
23
+ }>;
24
+ /** Zod schemas and grant operations closed over one host's capability vocabulary. */
25
+ export type GrantVocabulary<Capability extends string> = Readonly<{
26
+ /** Validates one capability name against the declared vocabulary. */
27
+ capabilityNameSchema: z.ZodType<Capability>;
28
+ /** Validates one grant: a vocabulary capability bound to its targets. */
29
+ grantSchema: z.ZodType<Grant<Capability>>;
30
+ /** Validates a bounded, 1-to-64-grant scope. */
31
+ scopeSchema: z.ZodType<Scope<Capability>>;
32
+ /** Dedupes and sorts grants into one canonical, order-independent form. */
33
+ canonicalGrants: (grants: readonly Grant<Capability>[]) => readonly Grant<Capability>[];
34
+ /** Whether some grant admits this capability against this optional resource. */
35
+ grantsAllow: (grants: readonly Grant<Capability>[], capability: Capability, resourceId?: string) => boolean;
36
+ /** Whether some grant admits this capability for at least one resource, without naming one. */
37
+ grantsList: (grants: readonly Grant<Capability>[], capability: Capability) => boolean;
38
+ /** Whether every grant in `child` is already admitted by `parent`. */
39
+ isSubset: (child: readonly Grant<Capability>[], parent: readonly Grant<Capability>[]) => boolean;
40
+ }>;
41
+ /** A host's capability vocabulary and its optional implied-capability classes. */
42
+ export type GrantsDefinition<Capability extends readonly [string, ...string[]]> = Readonly<{
43
+ /** Every capability name a grant may name; at least one. */
44
+ capabilities: Capability;
45
+ /**
46
+ * Extra capabilities a grant's capability also admits, for example a
47
+ * manage capability that also admits a narrower read capability. Every
48
+ * key and value must itself be a declared capability.
49
+ *
50
+ * One level deep, not transitive: if `a` implies `b` and `b` implies `c`,
51
+ * a grant of `a` does not reach `c` unless `a` names `c` directly too.
52
+ */
53
+ implies?: Readonly<Partial<Record<Capability[number], readonly Capability[number][]>>>;
54
+ }>;
55
+ /**
56
+ * Rejected when a host's capability vocabulary violates `defineGrants`'
57
+ * invariants.
58
+ *
59
+ * This error retains neither the rejected definition nor a foreign cause.
60
+ */
61
+ export declare class GrantsDefinitionError extends Error {
62
+ /** Stable public error name. */
63
+ readonly name = "GrantsDefinitionError";
64
+ /** Creates a fresh bounded definition error. */
65
+ constructor();
66
+ }
67
+ /**
68
+ * Declares one host's capability vocabulary and the zod schemas and grant
69
+ * operations closed over it.
70
+ *
71
+ * @throws {@link GrantsDefinitionError} When the vocabulary repeats a
72
+ * capability, or `implies` names a capability outside it.
73
+ */
74
+ export declare function defineGrants<const Capability extends readonly [string, ...string[]]>(definition: GrantsDefinition<Capability>): GrantVocabulary<Capability[number]>;
package/dist/grants.js ADDED
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Rejected when a host's capability vocabulary violates `defineGrants`'
4
+ * invariants.
5
+ *
6
+ * This error retains neither the rejected definition nor a foreign cause.
7
+ */
8
+ export class GrantsDefinitionError extends Error {
9
+ /** Stable public error name. */
10
+ name = "GrantsDefinitionError";
11
+ /** Creates a fresh bounded definition error. */
12
+ constructor() {
13
+ super("Ear Tag grants vocabulary definition is invalid");
14
+ }
15
+ }
16
+ const capabilitiesSchema = z.array(z.string().min(1).max(128)).min(1).max(256);
17
+ function parseImplies(definition) {
18
+ if (!capabilitiesSchema.safeParse(definition.capabilities).success)
19
+ throw new GrantsDefinitionError();
20
+ const capabilitySet = new Set(definition.capabilities);
21
+ if (capabilitySet.size !== definition.capabilities.length)
22
+ throw new GrantsDefinitionError();
23
+ // Object.entries widens the declared literal keys to `string`; every key
24
+ // and value is checked against `capabilitySet` below, and the returned map
25
+ // is itself keyed by `string`, so the vocabulary's literal union is never
26
+ // relied on again here.
27
+ const declaredImplies = definition.implies;
28
+ const impliesEntries = declaredImplies === undefined ? [] : Object.entries(declaredImplies);
29
+ for (const [capability, implied] of impliesEntries) {
30
+ if (!capabilitySet.has(capability))
31
+ throw new GrantsDefinitionError();
32
+ for (const impliedCapability of implied ?? [])
33
+ if (!capabilitySet.has(impliedCapability))
34
+ throw new GrantsDefinitionError();
35
+ }
36
+ return new Map(impliesEntries.map(([capability, implied]) => [capability, implied ?? []]));
37
+ }
38
+ /**
39
+ * Declares one host's capability vocabulary and the zod schemas and grant
40
+ * operations closed over it.
41
+ *
42
+ * @throws {@link GrantsDefinitionError} When the vocabulary repeats a
43
+ * capability, or `implies` names a capability outside it.
44
+ */
45
+ export function defineGrants(definition) {
46
+ const implies = parseImplies(definition);
47
+ const capabilityNameSchema = z.enum(definition.capabilities);
48
+ const grantTargetsSchema = z.discriminatedUnion("kind", [
49
+ z.strictObject({ kind: z.literal("all") }),
50
+ z.strictObject({
51
+ kind: z.literal("selected"),
52
+ ids: z.array(z.string().min(1).max(256)).max(256),
53
+ }),
54
+ ]);
55
+ const grantSchema = z.strictObject({
56
+ capability: capabilityNameSchema,
57
+ targets: grantTargetsSchema,
58
+ });
59
+ const scopeSchema = z.strictObject({
60
+ grants: z.array(grantSchema).min(1).max(64),
61
+ });
62
+ // Merges every grant sharing a capability before canonicalizing, so two
63
+ // scopes naming the same authority in a different order, or split across
64
+ // separate grants for the same capability, always produce one identical
65
+ // result: a reordered retry never lands a spurious replay `conflict`.
66
+ const canonicalGrants = (grants) => {
67
+ const merged = new Map();
68
+ for (const grant of grants) {
69
+ const existing = merged.get(grant.capability);
70
+ if (existing === undefined) {
71
+ merged.set(grant.capability, grant.targets.kind === "all"
72
+ ? grant.targets
73
+ : { kind: "selected", ids: [...grant.targets.ids] });
74
+ continue;
75
+ }
76
+ merged.set(grant.capability, existing.kind === "all" || grant.targets.kind === "all"
77
+ ? { kind: "all" }
78
+ : {
79
+ kind: "selected",
80
+ ids: [...existing.ids, ...grant.targets.ids],
81
+ });
82
+ }
83
+ // Plain code-unit order: stable and locale-independent, matching how
84
+ // `ids` below are already sorted.
85
+ const byCodeUnit = (left, right) => left < right ? -1 : left > right ? 1 : 0;
86
+ return [...merged.entries()]
87
+ .map(([capability, targets]) => ({
88
+ capability,
89
+ targets: targets.kind === "all"
90
+ ? targets
91
+ : { kind: "selected", ids: [...new Set(targets.ids)].sort() },
92
+ }))
93
+ .sort((left, right) => byCodeUnit(left.capability, right.capability));
94
+ };
95
+ const grantsAllow = (grants, capability, resourceId) => grants.some((grant) => (grant.capability === capability ||
96
+ implies.get(grant.capability)?.includes(capability) === true) &&
97
+ (grant.targets.kind === "all" ||
98
+ (resourceId !== undefined && grant.targets.ids.includes(resourceId))));
99
+ const grantsList = (grants, capability) => grants.some((grant) => grant.capability === capability &&
100
+ (grant.targets.kind === "all" || grant.targets.ids.length > 0));
101
+ const isSubset = (child, parent) => child.every((grant) => grant.targets.kind === "all"
102
+ ? grantsAllow(parent, grant.capability)
103
+ : grant.targets.ids.every((id) => grantsAllow(parent, grant.capability, id)));
104
+ return {
105
+ capabilityNameSchema,
106
+ grantSchema,
107
+ scopeSchema,
108
+ canonicalGrants,
109
+ grantsAllow,
110
+ grantsList,
111
+ isSubset,
112
+ };
113
+ }
@@ -0,0 +1,20 @@
1
+ /** Options for generating a bearer key. */
2
+ export type GenerateKeyOptions = Readonly<{
3
+ /** Number of random characters after the prefix. */
4
+ length?: number;
5
+ /** Public key-family marker, without the separating underscore. */
6
+ prefix?: string;
7
+ }>;
8
+ /** A newly generated bearer key and the digest safe to retain in storage. */
9
+ export type GeneratedKey = Readonly<{
10
+ /** SHA-256 digest suitable for persistence instead of the bearer secret. */
11
+ digest: string;
12
+ /** Bearer secret to disclose once to its authorized recipient. */
13
+ key: string;
14
+ }>;
15
+ /** Generate a cryptographically random bearer key. */
16
+ export declare function generateKey(options?: GenerateKeyOptions): string;
17
+ /** Hash a bearer key with Worker-compatible Web Crypto. */
18
+ export declare function hashKey(key: string): Promise<string>;
19
+ /** Generate a bearer key and the digest its owner should persist. */
20
+ export declare function generateKeyAndHash(options?: GenerateKeyOptions): Promise<GeneratedKey>;
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { LONG, nanoid } from "@fungi.computer/cairn";
2
+ /** Generate a cryptographically random bearer key. */
3
+ export function generateKey(options = {}) {
4
+ const key = nanoid(options.length ?? LONG);
5
+ return options.prefix === undefined ? key : `${options.prefix}_${key}`;
6
+ }
7
+ /** Hash a bearer key with Worker-compatible Web Crypto. */
8
+ export async function hashKey(key) {
9
+ const bytes = new TextEncoder().encode(key);
10
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
11
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
12
+ }
13
+ /** Generate a bearer key and the digest its owner should persist. */
14
+ export async function generateKeyAndHash(options = {}) {
15
+ const key = generateKey(options);
16
+ return { digest: await hashKey(key), key };
17
+ }
@@ -0,0 +1,8 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { KeySql } from "./keys.js";
3
+ /**
4
+ * Adapt a caller-owned `node:sqlite` database to the `KeySql` port
5
+ * `createKeyStore` expects, for hosts that run outside a Durable Object. The
6
+ * caller retains the database's lifetime.
7
+ */
8
+ export declare function fromNodeSqlite(database: DatabaseSync): KeySql;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ const nodeSqliteRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.null()]));
3
+ /**
4
+ * Adapt a caller-owned `node:sqlite` database to the `KeySql` port
5
+ * `createKeyStore` expects, for hosts that run outside a Durable Object. The
6
+ * caller retains the database's lifetime.
7
+ */
8
+ export function fromNodeSqlite(database) {
9
+ return {
10
+ exec: (statement, ...bindings) => {
11
+ const prepared = database.prepare(statement);
12
+ if (prepared.columns().length === 0) {
13
+ prepared.run(...bindings);
14
+ return { toArray: () => [] };
15
+ }
16
+ const rows = prepared
17
+ .all(...bindings)
18
+ .map((row) => nodeSqliteRowSchema.parse(row));
19
+ return { toArray: () => rows };
20
+ },
21
+ };
22
+ }