@nanobpm/urban 0.64.0 → 0.65.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.
@@ -0,0 +1,159 @@
1
+ // Slice S1 (binding) — context *identity*: the private-per-app /
2
+ // shared-on-same-name semantics.
3
+ //
4
+ // A context is named by its `(repo, ref)` pair. Two apps (or two Nano
5
+ // instances) that name the SAME context — the same normalised repo and the same
6
+ // ref — resolve to the SAME shared substrate; a distinct name stays private.
7
+ // This module turns a binding into a stable, canonical **identity key** that
8
+ // the resolver uses both to memoise in-process and to derive an on-disk
9
+ // location, so "same name ⇒ same substrate" holds within a process AND across
10
+ // processes/instances that share a cache root.
11
+ import { createHash } from "node:crypto";
12
+ import { isAbsolute, resolve as resolvePath } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ const SHORTHAND = /^[\w.-]+\/[\w.-]+$/;
15
+ const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
16
+ const SCP_LIKE = /^[\w.-]+@([\w.-]+):(.+)$/; // git@github.com:owner/name.git
17
+ function stripGitSuffix(value) {
18
+ // Trim trailing slashes BEFORE stripping `.git` so a trailing-slash spelling
19
+ // (`owner/name.git/`, a URL pathname ending `.git/`) still has its `.git`
20
+ // suffix removed — otherwise `.git$` would not match and equivalent repo
21
+ // spellings would canonicalise to different identities (different substrates).
22
+ // Trim once more afterwards to drop any slash exposed by removing the suffix.
23
+ return value.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
24
+ }
25
+ // A URL's scheme-default port is equivalent to omitting the port, so it must not
26
+ // split identity. `URL` already strips the default port for the web schemes it
27
+ // knows (http/https/ws/…), but NOT for git transports like ssh:// and git://, so
28
+ // their defaults are normalised here explicitly.
29
+ const DEFAULT_PORTS = {
30
+ "http:": "80",
31
+ "https:": "443",
32
+ "ssh:": "22",
33
+ "git:": "9418",
34
+ "ftp:": "21",
35
+ };
36
+ /**
37
+ * The canonical authority (`host` or `host:port`) for a scheme URL identity.
38
+ * Excludes any `user:pass@` userinfo (uses `hostname`, never `host`) so
39
+ * credentials never affect identity, and drops a scheme's DEFAULT port while
40
+ * retaining any NON-default port as a distinguishing factor.
41
+ */
42
+ function canonicalAuthority(parsed) {
43
+ const host = parsed.hostname.toLowerCase();
44
+ const defaultPort = DEFAULT_PORTS[parsed.protocol.toLowerCase()];
45
+ if (parsed.port && parsed.port !== defaultPort) {
46
+ return `${host}:${parsed.port}`;
47
+ }
48
+ return host;
49
+ }
50
+ /**
51
+ * Resolve a `file://` URL to an absolute filesystem path, honouring the host
52
+ * component and percent-encoding. Falls back to naive `file://` stripping for a
53
+ * malformed URL so identity stays stable rather than throwing.
54
+ */
55
+ function fileUrlToAbsPath(raw) {
56
+ try {
57
+ return resolvePath(fileURLToPath(raw));
58
+ }
59
+ catch {
60
+ return resolvePath(raw.slice("file://".length));
61
+ }
62
+ }
63
+ /**
64
+ * Classify a binding `repo` field into a concrete clone URL plus a normalised
65
+ * canonical form for identity. Public git is NOT hard-coded: local/`file://`
66
+ * paths and arbitrary git hosts are first-class, leaving a seam for a future
67
+ * PII/mutable or self-hosted substrate backend.
68
+ */
69
+ function classifyRepo(repo) {
70
+ const raw = repo.trim();
71
+ // Local filesystem path or file:// URL — used for tests and self-hosted
72
+ // substrates. Canonicalised by absolute path so two spellings of one path
73
+ // share identity. `file://` URLs are parsed via `fileURLToPath` so the host
74
+ // component (e.g. `file://localhost/…`) and percent-encoding are handled and
75
+ // equivalent spellings map to one identity.
76
+ if (raw.startsWith("file://")) {
77
+ const abs = fileUrlToAbsPath(raw);
78
+ return { url: raw, canonical: `file:${abs}` };
79
+ }
80
+ if (raw.startsWith(".") || isAbsolute(raw)) {
81
+ const abs = resolvePath(raw);
82
+ return { url: abs, canonical: `file:${abs}` };
83
+ }
84
+ // owner/name shorthand ⇒ GitHub HTTPS clone URL.
85
+ if (SHORTHAND.test(raw)) {
86
+ const canonical = `github.com/${stripGitSuffix(raw).toLowerCase()}`;
87
+ return { url: `https://github.com/${stripGitSuffix(raw)}.git`, canonical };
88
+ }
89
+ // scp-like git URL: git@host:owner/name(.git)
90
+ const scp = SCP_LIKE.exec(raw);
91
+ if (scp) {
92
+ const host = scp[1].toLowerCase();
93
+ const path = stripGitSuffix(scp[2]).toLowerCase();
94
+ return { url: raw, canonical: `${host}/${path}` };
95
+ }
96
+ // scheme URL: https://, ssh://, git://, http://
97
+ if (SCHEME_URL.test(raw)) {
98
+ try {
99
+ const parsed = new URL(raw);
100
+ // Build the authority from `hostname` (never `host`) so any `user:pass@`
101
+ // userinfo is excluded — credentials never affect identity. The port is
102
+ // *normalised*, not dropped: a scheme's DEFAULT port collapses to the
103
+ // portless spelling (so `https://h:443/o/n` ≡ `https://h/o/n` and
104
+ // `ssh://h:22/o/n` ≡ `ssh://h/o/n`), but a NON-default port is retained as
105
+ // a distinguishing factor — two remotes on the same host but different
106
+ // ports are genuinely different repos and MUST NOT share a substrate.
107
+ const host = canonicalAuthority(parsed);
108
+ const path = stripGitSuffix(parsed.pathname).replace(/^\/+/, "").toLowerCase();
109
+ return { url: raw, canonical: `${host}/${path}` };
110
+ }
111
+ catch {
112
+ // Fall through to the opaque case below.
113
+ }
114
+ }
115
+ // Anything else: treat opaquely but still stable.
116
+ return { url: raw, canonical: stripGitSuffix(raw).toLowerCase() };
117
+ }
118
+ function toSlug(canonical, ref, hash) {
119
+ const readable = `${canonical}@${ref}`
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9]+/g, "-")
122
+ .replace(/^-+|-+$/g, "")
123
+ .slice(0, 48);
124
+ // Suffix with the FULL identity key (sha256 hex), not a truncated prefix: the
125
+ // on-disk location is derived from the slug, so a truncated suffix would make
126
+ // two distinct keys collide onto one `localPath` (breaking "distinct names
127
+ // stay private") far sooner than the key itself collides. Deriving the suffix
128
+ // from the whole key keeps on-disk uniqueness exactly as strong as the
129
+ // in-process sharing key — a single source of truth, distinct keys ⇒ distinct
130
+ // slugs.
131
+ return `${readable || "context"}-${hash}`;
132
+ }
133
+ /**
134
+ * Compute the canonical {@link ContextIdentity} for a binding. Deterministic and
135
+ * pure: equal `(repo, ref)` names (after normalisation) always produce an equal
136
+ * `key` and `slug`. Distinct names are collision-resistant, not collision-proof:
137
+ * both `key` and `slug` are sha256-derived, so two distinct names collide only on
138
+ * a full sha256 collision (the `slug` embeds the whole `key`, so it is exactly as
139
+ * strong as the `key`).
140
+ */
141
+ export function resolveContextIdentity(binding) {
142
+ const { url, canonical } = classifyRepo(binding.repo);
143
+ const ref = binding.ref.trim();
144
+ const canonicalName = `${canonical}#${ref}`;
145
+ const key = createHash("sha256").update(canonicalName, "utf8").digest("hex");
146
+ return { key, repo: url, ref, slug: toSlug(canonical, ref, key) };
147
+ }
148
+ /**
149
+ * The stable identity key of a binding — the sharing primitive. Two bindings
150
+ * resolve to the same shared substrate iff this returns the same value for
151
+ * both.
152
+ */
153
+ export function contextIdentityKey(binding) {
154
+ return resolveContextIdentity(binding).key;
155
+ }
156
+ /** `true` iff two bindings name the same shared context (same substrate). */
157
+ export function sameContext(a, b) {
158
+ return contextIdentityKey(a) === contextIdentityKey(b);
159
+ }
@@ -1 +1,5 @@
1
- export {};
1
+ export { type ContextBinding, ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.ts";
2
+ export { type ContextIdentity, contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.ts";
3
+ export { type ResolvedContextHandle, type SubstrateBackend, type SubstrateResolveOptions, } from "./backend.ts";
4
+ export { GitSubstrateBackend, type GitRunner, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.ts";
5
+ export { ContextResolver, type ContextResolverOptions, type ResolveOptions, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.ts";
@@ -1 +1,14 @@
1
- export {};
1
+ // @nanobpm/urban/context/binding — slice S1.
2
+ //
3
+ // The bindable `context` resource: the binding descriptor (`uses: context:
4
+ // { repo, ref }`) plus local↔remote resolution to a resolved handle, with
5
+ // private-per-app / shared-on-same-name identity semantics. This is one half of
6
+ // the published contract nanobpm/nano-workforce#291 builds against.
7
+ //
8
+ // Downstream: S3 (git substrate/governance) and S4 (retrieval) consume the
9
+ // ResolvedContextHandle; the SubstrateBackend interface is the seam for a
10
+ // future PII/mutable or self-hosted backend.
11
+ export { ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.js";
12
+ export { contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.js";
13
+ export { GitSubstrateBackend, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.js";
14
+ export { ContextResolver, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.js";
@@ -0,0 +1,61 @@
1
+ import type { ResolvedContextHandle, SubstrateBackend } from "./backend.ts";
2
+ /** Options for a {@link ContextResolver}. */
3
+ export interface ContextResolverOptions {
4
+ /**
5
+ * Root directory under which per-context working copies are cloned. Two
6
+ * resolvers sharing a `cacheRoot` share substrates on disk. Defaults to
7
+ * {@link defaultContextCacheRoot}.
8
+ */
9
+ readonly cacheRoot?: string;
10
+ /**
11
+ * The substrate backend. Defaults to a git backend ({@link GitSubstrateBackend}).
12
+ * Injecting a different backend is the seam for a future PII/mutable or
13
+ * self-hosted substrate.
14
+ */
15
+ readonly backend?: SubstrateBackend;
16
+ }
17
+ /** Per-resolution overrides. */
18
+ export interface ResolveOptions {
19
+ /**
20
+ * Controls whether a re-resolution re-fetches the substrate. Note the
21
+ * {@link ContextResolver} memoises per identity: once an identity has resolved,
22
+ * repeat resolves return the cached handle and do **not** re-fetch unless you
23
+ * explicitly pass `refresh: true`, which forces a fresh materialisation (a
24
+ * fetch + re-pin on the backend). While a materialisation is still in flight,
25
+ * concurrent resolves — including `refresh: true` — coalesce onto it rather
26
+ * than starting a second one. The backend's own default (fetch on an existing
27
+ * clone) therefore only applies the first time an identity is materialised or
28
+ * when `refresh: true` is passed. Defaults to `undefined` (reuse the memoised
29
+ * handle).
30
+ */
31
+ readonly refresh?: boolean;
32
+ }
33
+ /**
34
+ * The default cache root for context working copies. Honours
35
+ * `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
36
+ * back to the OS temp dir when no home directory is available.
37
+ */
38
+ export declare function defaultContextCacheRoot(env?: Record<string, string | undefined>): string;
39
+ /**
40
+ * Resolves context bindings to concrete local working copies. A single resolver
41
+ * instance enforces shared-on-same-name in-process by memoising per identity
42
+ * key; distinct names stay private.
43
+ */
44
+ export declare class ContextResolver {
45
+ #private;
46
+ constructor(options?: ContextResolverOptions);
47
+ /** The cache root this resolver clones into. */
48
+ get cacheRoot(): string;
49
+ /**
50
+ * Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
51
+ * bindings that name the same context return the identical handle from this
52
+ * resolver; distinct names return distinct handles.
53
+ */
54
+ resolve(binding: unknown, options?: ResolveOptions): Promise<ResolvedContextHandle>;
55
+ }
56
+ /**
57
+ * One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
58
+ * long-lived {@link ContextResolver} when resolving many bindings so sharing is
59
+ * memoised across calls.
60
+ */
61
+ export declare function resolveContextBinding(binding: unknown, options?: ContextResolverOptions & ResolveOptions): Promise<ResolvedContextHandle>;
@@ -0,0 +1,125 @@
1
+ // Slice S1 (binding) — the resolver: bind → validate → identity → resolved
2
+ // handle, with private-per-app / shared-on-same-name semantics enforced two
3
+ // ways:
4
+ //
5
+ // 1. In-process: a ContextResolver memoises by identity key, so two resolves of
6
+ // the same name return the SAME handle (and never double-clone), while
7
+ // distinct names get distinct handles.
8
+ // 2. Across processes/instances: the on-disk location is derived
9
+ // deterministically from the identity slug under a shared cache root, so two
10
+ // Nano instances naming the same context share one working copy on disk.
11
+ // Known first-use limitation (deferred past slice S1): the single-flight in
12
+ // (1) is in-process only, so concurrent *first* resolves of the same context
13
+ // across instances can race materialisation (clone/pin). Sharing is reliable
14
+ // once the substrate exists; a follow-up slice adds an inter-process lock to
15
+ // make concurrent first-use safe too — see GitSubstrateBackend.materialise.
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { join, resolve as resolvePath } from "node:path";
18
+ import { parseContextBinding } from "./descriptor.js";
19
+ import { resolveContextIdentity } from "./identity.js";
20
+ import { GitSubstrateBackend } from "./git-backend.js";
21
+ /**
22
+ * The default cache root for context working copies. Honours
23
+ * `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
24
+ * back to the OS temp dir when no home directory is available.
25
+ */
26
+ export function defaultContextCacheRoot(env = process.env) {
27
+ if (env.URBAN_CONTEXT_CACHE_DIR)
28
+ return env.URBAN_CONTEXT_CACHE_DIR;
29
+ if (env.XDG_CACHE_HOME)
30
+ return join(env.XDG_CACHE_HOME, "urban", "context");
31
+ const home = safeHomedir();
32
+ if (home)
33
+ return join(home, ".cache", "urban", "context");
34
+ return join(tmpdir(), "urban", "context");
35
+ }
36
+ /** `os.homedir()`, but tolerant: returns `undefined` if the home dir can't be
37
+ * determined (Node can throw here) so callers fall back to the temp dir. */
38
+ function safeHomedir() {
39
+ try {
40
+ return homedir() || undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Resolves context bindings to concrete local working copies. A single resolver
48
+ * instance enforces shared-on-same-name in-process by memoising per identity
49
+ * key; distinct names stay private.
50
+ */
51
+ export class ContextResolver {
52
+ #cacheRoot;
53
+ #backend;
54
+ // Per-identity resolution record. Despite serving the single-flight guarantee,
55
+ // this map is NOT purged once a resolution settles: the settled record is
56
+ // retained as the long-lived memoisation cache backing shared-on-same-name, so
57
+ // repeat resolves return the identical handle. Entries are removed only on
58
+ // rejection (so a failed materialise can be retried); a fulfilled entry lives
59
+ // for the resolver's lifetime. Do not add "drop settled entries" cleanup here
60
+ // without also giving up memoisation.
61
+ #resolutions = new Map();
62
+ constructor(options = {}) {
63
+ // Normalise to an absolute path so a relative `cacheRoot` (or a relative
64
+ // `URBAN_CONTEXT_CACHE_DIR`) still yields an absolute `localPath`, honouring
65
+ // `SubstrateResolveOptions.localPath` ("Absolute path…"). Note this resolves
66
+ // a relative `cacheRoot` against *this* process's CWD, so the cross-process
67
+ // sharing promised in the module header only holds for an ABSOLUTE
68
+ // `cacheRoot`; a relative one is shared only among instances that happen to
69
+ // share a working directory.
70
+ this.#cacheRoot = resolvePath(options.cacheRoot ?? defaultContextCacheRoot());
71
+ this.#backend = options.backend ?? new GitSubstrateBackend();
72
+ }
73
+ /** The cache root this resolver clones into. */
74
+ get cacheRoot() {
75
+ return this.#cacheRoot;
76
+ }
77
+ /**
78
+ * Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
79
+ * bindings that name the same context return the identical handle from this
80
+ * resolver; distinct names return distinct handles.
81
+ */
82
+ async resolve(binding, options = {}) {
83
+ const parsed = parseContextBinding(binding);
84
+ const identity = resolveContextIdentity(parsed);
85
+ const localPath = join(this.#cacheRoot, identity.slug);
86
+ // Shared-on-same-name: concurrent or repeated resolves of one identity share
87
+ // a single materialisation. Coalesce onto an in-flight one regardless of
88
+ // `refresh` — it is already producing a fresh working copy, so starting a
89
+ // second `materialise()` would double-clone into the same `localPath` and
90
+ // break the single-in-flight guarantee. Only re-materialise once the prior
91
+ // resolution has settled AND the caller explicitly asked to `refresh`.
92
+ const existing = this.#resolutions.get(identity.key);
93
+ if (existing && (!existing.settled || options.refresh !== true)) {
94
+ return existing.promise;
95
+ }
96
+ const record = {
97
+ promise: this.#backend
98
+ .materialise(identity, { localPath, refresh: options.refresh })
99
+ .catch((error) => {
100
+ if (this.#resolutions.get(identity.key) === record) {
101
+ this.#resolutions.delete(identity.key);
102
+ }
103
+ throw error;
104
+ }),
105
+ settled: false,
106
+ };
107
+ this.#resolutions.set(identity.key, record);
108
+ record.promise.then(() => {
109
+ record.settled = true;
110
+ }, () => {
111
+ // Rejection is surfaced to the caller via the returned promise; the map
112
+ // entry is already removed in the catch above.
113
+ });
114
+ return record.promise;
115
+ }
116
+ }
117
+ /**
118
+ * One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
119
+ * long-lived {@link ContextResolver} when resolving many bindings so sharing is
120
+ * memoised across calls.
121
+ */
122
+ export function resolveContextBinding(binding, options = {}) {
123
+ const { cacheRoot, backend, ...resolveOptions } = options;
124
+ return new ContextResolver({ cacheRoot, backend }).resolve(binding, resolveOptions);
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,79 @@
1
+ // Slice S1 (binding) — the resolution seam: types shared by the resolver and
2
+ // any substrate backend.
3
+ //
4
+ // Resolution turns a validated binding into a concrete local working copy of
5
+ // the substrate, returning a **resolved handle** (local path + repo/ref
6
+ // identity). Downstream slices consume the handle: S3 (git substrate /
7
+ // governance) writes append-via-commit into `localPath`, and S4 (retrieval)
8
+ // reads from it. S1 owns clone/pull wiring ONLY — it exposes a clean handle and
9
+ // deliberately contains no commit/PR/write or governance logic.
10
+ //
11
+ // The `SubstrateBackend` interface is the seam that keeps resolution from
12
+ // hard-coding "the substrate is exclusively a public git repo": the default
13
+ // backend is git, but a future PII/mutable or self-hosted backend can implement
14
+ // the same interface without touching the resolver or callers.
15
+
16
+ import type { ContextIdentity } from "./identity.ts";
17
+
18
+ /**
19
+ * A concrete, resolved handle to a context substrate: a local working copy
20
+ * pinned to `ref`, plus its repo/ref identity. This is the value S3 and S4
21
+ * build on.
22
+ */
23
+ export interface ResolvedContextHandle {
24
+ /** Canonical identity of the bound context (see {@link ContextIdentity}). */
25
+ readonly identity: ContextIdentity;
26
+ /** Absolute path to the local working copy of the substrate. */
27
+ readonly localPath: string;
28
+ /** The concrete substrate repo URL/path that was cloned. */
29
+ readonly repo: string;
30
+ /**
31
+ * The ref the working copy is pinned to. This records the *intended* pin
32
+ * (`identity.ref`). It is a re-verified fact only when the working copy was
33
+ * just cloned or refreshed; on the `refresh: false` reuse path the existing
34
+ * working copy is trusted as-is and NOT re-pinned, so `ref` reflects the
35
+ * requested pin rather than a freshly-validated checkout (see
36
+ * {@link SubstrateResolveOptions.refresh}).
37
+ */
38
+ readonly ref: string;
39
+ }
40
+
41
+ /** Options passed to a backend when materialising a substrate working copy. */
42
+ export interface SubstrateResolveOptions {
43
+ /**
44
+ * Absolute path the working copy MUST live at. The resolver derives this
45
+ * deterministically from the identity so that the same name maps to the same
46
+ * on-disk substrate (shared-on-same-name). A backend rejects a relative path
47
+ * (see {@link SubstrateBackend.materialise}).
48
+ */
49
+ readonly localPath: string;
50
+ /**
51
+ * When `true`, refresh an already-present working copy (fetch + re-pin to
52
+ * `ref`). When `false`, an existing working copy is reused as-is without
53
+ * touching the network AND without running git at all — it is trusted as the
54
+ * requested substrate; no fetch, pin, or checkout validation is enforced, so
55
+ * the returned handle's `ref` reflects the intended pin rather than a
56
+ * re-verified checkout. Defaults to `true`.
57
+ */
58
+ readonly refresh?: boolean;
59
+ }
60
+
61
+ /**
62
+ * A pluggable substrate backend. The default is git (see `./git-backend.ts`),
63
+ * but this interface is the seam for a future PII/mutable or self-hosted
64
+ * backend — the resolver and its callers depend only on this contract.
65
+ */
66
+ export interface SubstrateBackend {
67
+ /**
68
+ * Materialise (clone on first use, refresh thereafter) the substrate for
69
+ * `identity` at `options.localPath`, pinned to `identity.ref`, and return the
70
+ * resolved handle. The pin is enforced on the clone and `refresh: true` paths;
71
+ * `refresh: false` reuses an existing working copy as-is without re-pinning
72
+ * (see {@link SubstrateResolveOptions.refresh}). `options.localPath` must be
73
+ * absolute; a relative path is rejected before any git command runs.
74
+ */
75
+ materialise(
76
+ identity: ContextIdentity,
77
+ options: SubstrateResolveOptions,
78
+ ): Promise<ResolvedContextHandle>;
79
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ ContextBindingError,
5
+ isContextBinding,
6
+ parseContextBinding,
7
+ } from "./descriptor.ts";
8
+
9
+ test("parseContextBinding accepts a well-formed { repo, ref } and trims", () => {
10
+ const binding = parseContextBinding({ repo: " owner/name ", ref: " main " });
11
+ assert.deepEqual(binding, { repo: "owner/name", ref: "main" });
12
+ });
13
+
14
+ test("parseContextBinding ignores unknown extra fields (forward-compatible)", () => {
15
+ const binding = parseContextBinding({ repo: "owner/name", ref: "v1", future: 42 });
16
+ assert.deepEqual(binding, { repo: "owner/name", ref: "v1" });
17
+ });
18
+
19
+ test("parseContextBinding rejects non-objects with a clear message", () => {
20
+ assert.throws(() => parseContextBinding(null), ContextBindingError);
21
+ assert.throws(() => parseContextBinding("owner/name#main"), /must be an object/);
22
+ assert.throws(() => parseContextBinding(undefined), ContextBindingError);
23
+ });
24
+
25
+ test("parseContextBinding rejects arrays as not-an-object (typeof [] === 'object')", () => {
26
+ assert.throws(() => parseContextBinding([]), /must be an object/);
27
+ assert.throws(() => parseContextBinding(["owner/name", "main"]), /must be an object/);
28
+ assert.equal(isContextBinding([]), false);
29
+ });
30
+
31
+ test("parseContextBinding names arrays as 'array', not 'object', in the error message", () => {
32
+ assert.throws(() => parseContextBinding([]), /received array/);
33
+ assert.throws(() => parseContextBinding(["owner/name", "main"]), /received array/);
34
+ });
35
+
36
+ test("parseContextBinding rejects a missing or empty repo", () => {
37
+ assert.throws(() => parseContextBinding({ ref: "main" }), /"repo" is required/);
38
+ assert.throws(() => parseContextBinding({ repo: " ", ref: "main" }), /"repo" is required/);
39
+ assert.throws(() => parseContextBinding({ repo: 5, ref: "main" }), /"repo" is required/);
40
+ });
41
+
42
+ test("parseContextBinding rejects a missing or empty ref", () => {
43
+ assert.throws(() => parseContextBinding({ repo: "owner/name" }), /"ref" is required/);
44
+ assert.throws(() => parseContextBinding({ repo: "owner/name", ref: "" }), /"ref" is required/);
45
+ });
46
+
47
+ test("isContextBinding is a total type guard", () => {
48
+ assert.equal(isContextBinding({ repo: "owner/name", ref: "main" }), true);
49
+ assert.equal(isContextBinding({ repo: "owner/name" }), false);
50
+ assert.equal(isContextBinding(42), false);
51
+ });
@@ -0,0 +1,108 @@
1
+ // Slice S1 (binding) — the binding *descriptor*: the manifest shape
2
+ // `uses: context: { repo, ref }`.
3
+ //
4
+ // This is one half of the published contract that the consumer repo
5
+ // (nanobpm/nano-workforce#291) builds against, so the shape and the exported
6
+ // type name are deliberately **minimal and version-friendly**: a context is
7
+ // named by the substrate `repo` it lives in and the `ref` it is pinned to, and
8
+ // nothing more is required to bind it. Additional, backward-compatible fields
9
+ // may be layered on later without breaking existing manifests.
10
+
11
+ /**
12
+ * A declared binding to an Urban **context** resource — the manifest shape
13
+ * authored under `uses: { context: { repo, ref } }`.
14
+ *
15
+ * A context is a git-backed, governed memory substrate (see ADR 0051 — a
16
+ * context behaves like an Urban datasource: a declared, bindable resource). The
17
+ * pair `(repo, ref)` is the *name* of the context: two apps that declare the
18
+ * same pair bind the same shared substrate; a different pair is a different,
19
+ * private context (see {@link contextIdentityKey} in `./identity.ts`).
20
+ *
21
+ * The type is intentionally small and additive-friendly — treat it as a stable
22
+ * contract. Unknown extra fields on input are tolerated by the validator but
23
+ * are not part of the guaranteed shape.
24
+ */
25
+ export interface ContextBinding {
26
+ /**
27
+ * The substrate repository that stores the context.
28
+ *
29
+ * Accepts an `owner/name` GitHub shorthand (resolved to
30
+ * `https://github.com/owner/name.git`), a full git URL
31
+ * (`https://…`, `ssh://…`, `git@host:owner/name.git`), or a local/`file://`
32
+ * path (used chiefly for tests and self-hosted substrates). A local path must
33
+ * be spelled unambiguously so it is not mistaken for the `owner/name`
34
+ * shorthand: use an absolute path, a `file://…` URL, or a `./`- or
35
+ * `../`-prefixed relative path. A bare relative path such as `subdir/repo`
36
+ * matches the shorthand and is treated as GitHub, not a local path. The MVP
37
+ * substrate is git-only, but resolution does not hard-code *public* git — see
38
+ * the backend seam in `./resolver.ts`.
39
+ */
40
+ readonly repo: string;
41
+
42
+ /**
43
+ * The ref the context is **pinned** to: a branch, tag, or commit SHA. First
44
+ * resolution clones the substrate; later resolutions refresh it to this ref.
45
+ */
46
+ readonly ref: string;
47
+ }
48
+
49
+ /** Error thrown when an untrusted value is not a valid {@link ContextBinding}. */
50
+ export class ContextBindingError extends Error {
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "ContextBindingError";
54
+ }
55
+ }
56
+
57
+ function isNonEmptyString(value: unknown): value is string {
58
+ return typeof value === "string" && value.trim().length > 0;
59
+ }
60
+
61
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+
65
+ /**
66
+ * Validate an untrusted value as a {@link ContextBinding}, throwing a
67
+ * {@link ContextBindingError} with a clear, field-scoped message on failure.
68
+ *
69
+ * Returns a *normalised* binding: `repo` and `ref` are trimmed. Extra fields on
70
+ * the input object are ignored (forward-compatible) — only `repo` and `ref` are
71
+ * carried through.
72
+ */
73
+ export function parseContextBinding(value: unknown): ContextBinding {
74
+ if (!isObjectRecord(value)) {
75
+ throw new ContextBindingError(
76
+ `context binding must be an object of the form { repo, ref }, received ${describe(value)}`,
77
+ );
78
+ }
79
+ const record = value;
80
+ if (!("repo" in record) || !isNonEmptyString(record.repo)) {
81
+ throw new ContextBindingError(
82
+ `context binding "repo" is required and must be a non-empty string, received ${describe(record.repo)}`,
83
+ );
84
+ }
85
+ if (!("ref" in record) || !isNonEmptyString(record.ref)) {
86
+ throw new ContextBindingError(
87
+ `context binding "ref" is required and must be a non-empty string, received ${describe(record.ref)}`,
88
+ );
89
+ }
90
+ return { repo: record.repo.trim(), ref: record.ref.trim() };
91
+ }
92
+
93
+ /** Type guard: `true` iff `value` is a valid {@link ContextBinding}. */
94
+ export function isContextBinding(value: unknown): value is ContextBinding {
95
+ try {
96
+ parseContextBinding(value);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function describe(value: unknown): string {
104
+ if (typeof value === "string") return JSON.stringify(value);
105
+ if (value === null) return "null";
106
+ if (Array.isArray(value)) return "array";
107
+ return typeof value;
108
+ }