@portalshq/contracts 0.0.2 → 0.0.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/dist/capabilities.d.ts +94 -0
- package/dist/capabilities.js +102 -0
- package/dist/capability-contract.d.ts +81 -0
- package/dist/capability-contract.js +25 -0
- package/dist/channel-manifest.schema.d.ts +114 -0
- package/dist/channel-manifest.schema.js +31 -0
- package/{src/content.ts → dist/content.d.ts} +15 -20
- package/dist/content.js +15 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/{src/tenancy.ts → dist/tenancy.d.ts} +12 -48
- package/dist/tenancy.js +57 -0
- package/package.json +15 -2
- package/src/capabilities.ts +0 -179
- package/src/capability-contract.ts +0 -49
- package/src/channel-manifest.schema.ts +0 -34
- package/src/index.ts +0 -5
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability contract. A way for a unit of platform functionality
|
|
3
|
+
* (chat, polls, lobby presence, a video player, a VR render target, a
|
|
4
|
+
* game-rules engine -- built or not-yet-built) to declare itself, and a
|
|
5
|
+
* way for a channel to compose the capabilities it needs.
|
|
6
|
+
*
|
|
7
|
+
* Design principle enforced here: a platform that silently no-ops an
|
|
8
|
+
* unbuilt or misconfigured capability is more dangerous than one that
|
|
9
|
+
* refuses to resolve. `CapabilityRegistry.resolve()` fails loud, with a
|
|
10
|
+
* specific error per problem, never a silent partial result. The same
|
|
11
|
+
* principle extends to tenancy below: a manifest cannot assert its own
|
|
12
|
+
* trust level -- that comes from a trusted lookup the caller provides,
|
|
13
|
+
* never from the (potentially untrusted) manifest itself.
|
|
14
|
+
*/
|
|
15
|
+
import type { TenantContext } from "./tenancy.js";
|
|
16
|
+
export interface ValidationResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
errors?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface RegistryCapabilityContext<TConfig = unknown> {
|
|
21
|
+
channelId: string;
|
|
22
|
+
tenant: TenantContext;
|
|
23
|
+
config: TConfig;
|
|
24
|
+
}
|
|
25
|
+
export interface CapabilityModule<TConfig = unknown> {
|
|
26
|
+
/** Globally unique, namespaced, e.g. "platform.chat", "platform.video.mux". */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Semver. Used for compatibility checks once external developers ship modules. */
|
|
29
|
+
version: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
/** Other capability ids this one requires to function, resolved per-channel. */
|
|
32
|
+
dependsOn?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Validate a channel's config for this capability. Must not throw for
|
|
35
|
+
* ordinary invalid input -- return a ValidationResult so the registry can
|
|
36
|
+
* collect every problem in one pass instead of failing on the first.
|
|
37
|
+
*/
|
|
38
|
+
validateConfig(config: unknown): ValidationResult;
|
|
39
|
+
/** Called once when a channel using this capability is first resolved. */
|
|
40
|
+
onChannelRegistered?(ctx: RegistryCapabilityContext<TConfig>): Promise<void>;
|
|
41
|
+
/** Called when the channel is torn down or the capability is removed. */
|
|
42
|
+
onChannelUnregistered?(ctx: RegistryCapabilityContext<TConfig>): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface CapabilityUsage {
|
|
45
|
+
capabilityId: string;
|
|
46
|
+
config: unknown;
|
|
47
|
+
}
|
|
48
|
+
export interface ChannelManifestConfig {
|
|
49
|
+
channelId: string;
|
|
50
|
+
/** Declared owner. Verified against the trusted TenantContext passed into
|
|
51
|
+
* `resolve()`, not taken on faith -- see the mismatch check below. */
|
|
52
|
+
tenantId: string;
|
|
53
|
+
displayName: string;
|
|
54
|
+
capabilities: CapabilityUsage[];
|
|
55
|
+
}
|
|
56
|
+
export interface ResolvedCapability {
|
|
57
|
+
module: CapabilityModule;
|
|
58
|
+
config: unknown;
|
|
59
|
+
}
|
|
60
|
+
export type ResolvedChannel = {
|
|
61
|
+
ok: true;
|
|
62
|
+
channelId: string;
|
|
63
|
+
tenant: TenantContext;
|
|
64
|
+
capabilities: ResolvedCapability[];
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
channelId: string;
|
|
68
|
+
errors: string[];
|
|
69
|
+
};
|
|
70
|
+
export declare class CapabilityRegistry {
|
|
71
|
+
private modules;
|
|
72
|
+
register(module: CapabilityModule): void;
|
|
73
|
+
get(id: string): CapabilityModule | undefined;
|
|
74
|
+
has(id: string): boolean;
|
|
75
|
+
list(): CapabilityModule[];
|
|
76
|
+
/**
|
|
77
|
+
* Resolves a manifest against currently-registered modules, for a given
|
|
78
|
+
* (trustworthily-sourced) tenant. Collects EVERY problem in a single pass
|
|
79
|
+
* rather than stopping at the first, so a developer sees the whole
|
|
80
|
+
* picture at once. `tenant` must come from your own tenant lookup (e.g.
|
|
81
|
+
* the control plane's database), never from the manifest itself -- a
|
|
82
|
+
* manifest declaring its own trust level would let any tenant grant
|
|
83
|
+
* themselves unlimited quota.
|
|
84
|
+
*/
|
|
85
|
+
resolve(manifest: ChannelManifestConfig, tenant: TenantContext): ResolvedChannel;
|
|
86
|
+
/** Runs onChannelRegistered for every resolved capability, in declaration order. */
|
|
87
|
+
activate(resolved: Extract<ResolvedChannel, {
|
|
88
|
+
ok: true;
|
|
89
|
+
}>): Promise<void>;
|
|
90
|
+
/** Runs onChannelUnregistered for every resolved capability, in reverse order. */
|
|
91
|
+
deactivate(resolved: Extract<ResolvedChannel, {
|
|
92
|
+
ok: true;
|
|
93
|
+
}>): Promise<void>;
|
|
94
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability contract. A way for a unit of platform functionality
|
|
3
|
+
* (chat, polls, lobby presence, a video player, a VR render target, a
|
|
4
|
+
* game-rules engine -- built or not-yet-built) to declare itself, and a
|
|
5
|
+
* way for a channel to compose the capabilities it needs.
|
|
6
|
+
*
|
|
7
|
+
* Design principle enforced here: a platform that silently no-ops an
|
|
8
|
+
* unbuilt or misconfigured capability is more dangerous than one that
|
|
9
|
+
* refuses to resolve. `CapabilityRegistry.resolve()` fails loud, with a
|
|
10
|
+
* specific error per problem, never a silent partial result. The same
|
|
11
|
+
* principle extends to tenancy below: a manifest cannot assert its own
|
|
12
|
+
* trust level -- that comes from a trusted lookup the caller provides,
|
|
13
|
+
* never from the (potentially untrusted) manifest itself.
|
|
14
|
+
*/
|
|
15
|
+
// ── Registry ─────────────────────────────────────────────────────────────
|
|
16
|
+
export class CapabilityRegistry {
|
|
17
|
+
modules = new Map();
|
|
18
|
+
register(module) {
|
|
19
|
+
if (this.modules.has(module.id)) {
|
|
20
|
+
throw new Error(`Capability "${module.id}" is already registered.`);
|
|
21
|
+
}
|
|
22
|
+
this.modules.set(module.id, module);
|
|
23
|
+
}
|
|
24
|
+
get(id) {
|
|
25
|
+
return this.modules.get(id);
|
|
26
|
+
}
|
|
27
|
+
has(id) {
|
|
28
|
+
return this.modules.has(id);
|
|
29
|
+
}
|
|
30
|
+
list() {
|
|
31
|
+
return [...this.modules.values()];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves a manifest against currently-registered modules, for a given
|
|
35
|
+
* (trustworthily-sourced) tenant. Collects EVERY problem in a single pass
|
|
36
|
+
* rather than stopping at the first, so a developer sees the whole
|
|
37
|
+
* picture at once. `tenant` must come from your own tenant lookup (e.g.
|
|
38
|
+
* the control plane's database), never from the manifest itself -- a
|
|
39
|
+
* manifest declaring its own trust level would let any tenant grant
|
|
40
|
+
* themselves unlimited quota.
|
|
41
|
+
*/
|
|
42
|
+
resolve(manifest, tenant) {
|
|
43
|
+
if (tenant.tenantId !== manifest.tenantId) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
channelId: manifest.channelId,
|
|
47
|
+
errors: [
|
|
48
|
+
`Tenant mismatch: manifest declares tenant "${manifest.tenantId}" but ` +
|
|
49
|
+
`resolution was requested for tenant "${tenant.tenantId}".`,
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const errors = [];
|
|
54
|
+
const resolved = [];
|
|
55
|
+
const declaredIds = new Set(manifest.capabilities.map((u) => u.capabilityId));
|
|
56
|
+
for (const usage of manifest.capabilities) {
|
|
57
|
+
const module = this.modules.get(usage.capabilityId);
|
|
58
|
+
if (!module) {
|
|
59
|
+
errors.push(`Channel "${manifest.channelId}" declares capability "${usage.capabilityId}", ` +
|
|
60
|
+
`which is not registered on this platform instance.`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const dep of module.dependsOn ?? []) {
|
|
64
|
+
if (!declaredIds.has(dep)) {
|
|
65
|
+
errors.push(`Capability "${module.id}" requires "${dep}", which channel ` +
|
|
66
|
+
`"${manifest.channelId}" does not declare.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const validation = module.validateConfig(usage.config);
|
|
70
|
+
if (!validation.ok) {
|
|
71
|
+
const detail = validation.errors?.join("; ") ?? "invalid config";
|
|
72
|
+
errors.push(`Invalid config for capability "${module.id}": ${detail}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
resolved.push({ module, config: usage.config });
|
|
76
|
+
}
|
|
77
|
+
if (errors.length > 0) {
|
|
78
|
+
return { ok: false, channelId: manifest.channelId, errors };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, channelId: manifest.channelId, tenant, capabilities: resolved };
|
|
81
|
+
}
|
|
82
|
+
/** Runs onChannelRegistered for every resolved capability, in declaration order. */
|
|
83
|
+
async activate(resolved) {
|
|
84
|
+
for (const { module, config } of resolved.capabilities) {
|
|
85
|
+
await module.onChannelRegistered?.({
|
|
86
|
+
channelId: resolved.channelId,
|
|
87
|
+
tenant: resolved.tenant,
|
|
88
|
+
config,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Runs onChannelUnregistered for every resolved capability, in reverse order. */
|
|
93
|
+
async deactivate(resolved) {
|
|
94
|
+
for (const { module, config } of [...resolved.capabilities].reverse()) {
|
|
95
|
+
await module.onChannelUnregistered?.({
|
|
96
|
+
channelId: resolved.channelId,
|
|
97
|
+
tenant: resolved.tenant,
|
|
98
|
+
config,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A Capability is the smallest unit of composition in the platform.
|
|
4
|
+
* Every capability — realtime-fanout, video-delivery, identity, the
|
|
5
|
+
* narrative-engine-adapter, everything — implements this same contract
|
|
6
|
+
* shape. The runtime never calls a capability directly; it always goes
|
|
7
|
+
* through the registry, which validates against this schema first.
|
|
8
|
+
*
|
|
9
|
+
* Modeled deliberately on Kubernetes CRD + controller semantics, not on
|
|
10
|
+
* a bare RPC interface: declare a resource shape, let the runtime
|
|
11
|
+
* reconcile it. See docs/architecture-decision-records for rationale.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PermissionScopeSchema: z.ZodObject<{
|
|
14
|
+
resource: z.ZodString;
|
|
15
|
+
effect: z.ZodEnum<["allow", "deny"]>;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
resource: string;
|
|
18
|
+
effect: "allow" | "deny";
|
|
19
|
+
}, {
|
|
20
|
+
resource: string;
|
|
21
|
+
effect: "allow" | "deny";
|
|
22
|
+
}>;
|
|
23
|
+
export type PermissionScope = z.infer<typeof PermissionScopeSchema>;
|
|
24
|
+
export declare const CapabilityContractSchema: z.ZodObject<{
|
|
25
|
+
id: z.ZodString;
|
|
26
|
+
version: z.ZodString;
|
|
27
|
+
inputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
28
|
+
outputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
29
|
+
permissions: z.ZodArray<z.ZodObject<{
|
|
30
|
+
resource: z.ZodString;
|
|
31
|
+
effect: z.ZodEnum<["allow", "deny"]>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
resource: string;
|
|
34
|
+
effect: "allow" | "deny";
|
|
35
|
+
}, {
|
|
36
|
+
resource: string;
|
|
37
|
+
effect: "allow" | "deny";
|
|
38
|
+
}>, "many">;
|
|
39
|
+
plane: z.ZodEnum<["control", "data"]>;
|
|
40
|
+
lazyStart: z.ZodDefault<z.ZodBoolean>;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
id: string;
|
|
43
|
+
version: string;
|
|
44
|
+
inputSchema: Record<string, unknown>;
|
|
45
|
+
outputSchema: Record<string, unknown>;
|
|
46
|
+
permissions: {
|
|
47
|
+
resource: string;
|
|
48
|
+
effect: "allow" | "deny";
|
|
49
|
+
}[];
|
|
50
|
+
plane: "control" | "data";
|
|
51
|
+
lazyStart: boolean;
|
|
52
|
+
}, {
|
|
53
|
+
id: string;
|
|
54
|
+
version: string;
|
|
55
|
+
inputSchema: Record<string, unknown>;
|
|
56
|
+
outputSchema: Record<string, unknown>;
|
|
57
|
+
permissions: {
|
|
58
|
+
resource: string;
|
|
59
|
+
effect: "allow" | "deny";
|
|
60
|
+
}[];
|
|
61
|
+
plane: "control" | "data";
|
|
62
|
+
lazyStart?: boolean | undefined;
|
|
63
|
+
}>;
|
|
64
|
+
export type CapabilityContract = z.infer<typeof CapabilityContractSchema>;
|
|
65
|
+
/**
|
|
66
|
+
* Implemented by every capability package. The registry calls `invoke`
|
|
67
|
+
* after validating `input` against the contract's inputSchema. Capabilities
|
|
68
|
+
* never call each other directly — composition happens at the channel
|
|
69
|
+
* manifest level, mediated by the runtime.
|
|
70
|
+
*/
|
|
71
|
+
export interface Capability<TInput = unknown, TOutput = unknown> {
|
|
72
|
+
contract: CapabilityContract;
|
|
73
|
+
invoke(input: TInput, ctx: CapabilityContext): Promise<TOutput>;
|
|
74
|
+
}
|
|
75
|
+
export interface CapabilityContext {
|
|
76
|
+
sessionId: string;
|
|
77
|
+
channelId: string;
|
|
78
|
+
worldId: string;
|
|
79
|
+
/** Resolves a NAP address to a narrative/world resource. See @portalshq/resolver. */
|
|
80
|
+
resolve: (napAddress: string) => Promise<unknown>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A Capability is the smallest unit of composition in the platform.
|
|
4
|
+
* Every capability — realtime-fanout, video-delivery, identity, the
|
|
5
|
+
* narrative-engine-adapter, everything — implements this same contract
|
|
6
|
+
* shape. The runtime never calls a capability directly; it always goes
|
|
7
|
+
* through the registry, which validates against this schema first.
|
|
8
|
+
*
|
|
9
|
+
* Modeled deliberately on Kubernetes CRD + controller semantics, not on
|
|
10
|
+
* a bare RPC interface: declare a resource shape, let the runtime
|
|
11
|
+
* reconcile it. See docs/architecture-decision-records for rationale.
|
|
12
|
+
*/
|
|
13
|
+
export const PermissionScopeSchema = z.object({
|
|
14
|
+
resource: z.string(), // e.g. "world:write", "audience:read"
|
|
15
|
+
effect: z.enum(["allow", "deny"]),
|
|
16
|
+
});
|
|
17
|
+
export const CapabilityContractSchema = z.object({
|
|
18
|
+
id: z.string(), // e.g. "realtime-fanout"
|
|
19
|
+
version: z.string(), // semver
|
|
20
|
+
inputSchema: z.record(z.unknown()), // JSON schema for capability input
|
|
21
|
+
outputSchema: z.record(z.unknown()), // JSON schema for capability output
|
|
22
|
+
permissions: z.array(PermissionScopeSchema),
|
|
23
|
+
plane: z.enum(["control", "data"]), // which plane this capability runs in — see ADR 0001
|
|
24
|
+
lazyStart: z.boolean().default(true), // billed only while an invoking session is active
|
|
25
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A Channel is a declarative composition of capabilities + a world template.
|
|
4
|
+
* Developers write one of these instead of writing infrastructure. This is
|
|
5
|
+
* the file the SDK CLI reads, validates, and deploys.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately format-agnostic: a branching-film channel, a live game-show
|
|
8
|
+
* channel, and a text/image channel are all just different capability
|
|
9
|
+
* selections against the same manifest shape — there is no separate
|
|
10
|
+
* "channel type" concept in the runtime.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CapabilityRefSchema: z.ZodObject<{
|
|
13
|
+
capabilityId: z.ZodString;
|
|
14
|
+
version: z.ZodOptional<z.ZodString>;
|
|
15
|
+
config: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
capabilityId: string;
|
|
18
|
+
config?: Record<string, unknown> | undefined;
|
|
19
|
+
version?: string | undefined;
|
|
20
|
+
}, {
|
|
21
|
+
capabilityId: string;
|
|
22
|
+
config?: Record<string, unknown> | undefined;
|
|
23
|
+
version?: string | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
export declare const ChannelManifestSchema: z.ZodObject<{
|
|
26
|
+
apiVersion: z.ZodLiteral<"nap/v1">;
|
|
27
|
+
kind: z.ZodLiteral<"Channel">;
|
|
28
|
+
metadata: z.ZodObject<{
|
|
29
|
+
name: z.ZodString;
|
|
30
|
+
owner: z.ZodString;
|
|
31
|
+
description: z.ZodOptional<z.ZodString>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
name: string;
|
|
34
|
+
owner: string;
|
|
35
|
+
description?: string | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
name: string;
|
|
38
|
+
owner: string;
|
|
39
|
+
description?: string | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
spec: z.ZodObject<{
|
|
42
|
+
worldTemplate: z.ZodString;
|
|
43
|
+
narrativeRef: z.ZodOptional<z.ZodString>;
|
|
44
|
+
capabilities: z.ZodArray<z.ZodObject<{
|
|
45
|
+
capabilityId: z.ZodString;
|
|
46
|
+
version: z.ZodOptional<z.ZodString>;
|
|
47
|
+
config: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
48
|
+
}, "strip", z.ZodTypeAny, {
|
|
49
|
+
capabilityId: string;
|
|
50
|
+
config?: Record<string, unknown> | undefined;
|
|
51
|
+
version?: string | undefined;
|
|
52
|
+
}, {
|
|
53
|
+
capabilityId: string;
|
|
54
|
+
config?: Record<string, unknown> | undefined;
|
|
55
|
+
version?: string | undefined;
|
|
56
|
+
}>, "many">;
|
|
57
|
+
visibility: z.ZodDefault<z.ZodEnum<["public", "unlisted", "private"]>>;
|
|
58
|
+
}, "strip", z.ZodTypeAny, {
|
|
59
|
+
capabilities: {
|
|
60
|
+
capabilityId: string;
|
|
61
|
+
config?: Record<string, unknown> | undefined;
|
|
62
|
+
version?: string | undefined;
|
|
63
|
+
}[];
|
|
64
|
+
worldTemplate: string;
|
|
65
|
+
visibility: "public" | "unlisted" | "private";
|
|
66
|
+
narrativeRef?: string | undefined;
|
|
67
|
+
}, {
|
|
68
|
+
capabilities: {
|
|
69
|
+
capabilityId: string;
|
|
70
|
+
config?: Record<string, unknown> | undefined;
|
|
71
|
+
version?: string | undefined;
|
|
72
|
+
}[];
|
|
73
|
+
worldTemplate: string;
|
|
74
|
+
narrativeRef?: string | undefined;
|
|
75
|
+
visibility?: "public" | "unlisted" | "private" | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
}, "strip", z.ZodTypeAny, {
|
|
78
|
+
apiVersion: "nap/v1";
|
|
79
|
+
kind: "Channel";
|
|
80
|
+
metadata: {
|
|
81
|
+
name: string;
|
|
82
|
+
owner: string;
|
|
83
|
+
description?: string | undefined;
|
|
84
|
+
};
|
|
85
|
+
spec: {
|
|
86
|
+
capabilities: {
|
|
87
|
+
capabilityId: string;
|
|
88
|
+
config?: Record<string, unknown> | undefined;
|
|
89
|
+
version?: string | undefined;
|
|
90
|
+
}[];
|
|
91
|
+
worldTemplate: string;
|
|
92
|
+
visibility: "public" | "unlisted" | "private";
|
|
93
|
+
narrativeRef?: string | undefined;
|
|
94
|
+
};
|
|
95
|
+
}, {
|
|
96
|
+
apiVersion: "nap/v1";
|
|
97
|
+
kind: "Channel";
|
|
98
|
+
metadata: {
|
|
99
|
+
name: string;
|
|
100
|
+
owner: string;
|
|
101
|
+
description?: string | undefined;
|
|
102
|
+
};
|
|
103
|
+
spec: {
|
|
104
|
+
capabilities: {
|
|
105
|
+
capabilityId: string;
|
|
106
|
+
config?: Record<string, unknown> | undefined;
|
|
107
|
+
version?: string | undefined;
|
|
108
|
+
}[];
|
|
109
|
+
worldTemplate: string;
|
|
110
|
+
narrativeRef?: string | undefined;
|
|
111
|
+
visibility?: "public" | "unlisted" | "private" | undefined;
|
|
112
|
+
};
|
|
113
|
+
}>;
|
|
114
|
+
export type ChannelManifest = z.infer<typeof ChannelManifestSchema>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A Channel is a declarative composition of capabilities + a world template.
|
|
4
|
+
* Developers write one of these instead of writing infrastructure. This is
|
|
5
|
+
* the file the SDK CLI reads, validates, and deploys.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately format-agnostic: a branching-film channel, a live game-show
|
|
8
|
+
* channel, and a text/image channel are all just different capability
|
|
9
|
+
* selections against the same manifest shape — there is no separate
|
|
10
|
+
* "channel type" concept in the runtime.
|
|
11
|
+
*/
|
|
12
|
+
export const CapabilityRefSchema = z.object({
|
|
13
|
+
capabilityId: z.string(), // must match a CapabilityContract.id in the registry
|
|
14
|
+
version: z.string().optional(), // pin a version; defaults to latest compatible
|
|
15
|
+
config: z.record(z.unknown()).optional(),
|
|
16
|
+
});
|
|
17
|
+
export const ChannelManifestSchema = z.object({
|
|
18
|
+
apiVersion: z.literal("nap/v1"),
|
|
19
|
+
kind: z.literal("Channel"),
|
|
20
|
+
metadata: z.object({
|
|
21
|
+
name: z.string(),
|
|
22
|
+
owner: z.string(), // provider/developer id
|
|
23
|
+
description: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
spec: z.object({
|
|
26
|
+
worldTemplate: z.string(), // NAP address of the world template
|
|
27
|
+
narrativeRef: z.string().optional(), // NAP address, if this channel uses narrative state
|
|
28
|
+
capabilities: z.array(CapabilityRefSchema).min(1),
|
|
29
|
+
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
@@ -12,22 +12,18 @@
|
|
|
12
12
|
* and regulatory requirements (COPPA and equivalents) need real legal
|
|
13
13
|
* review -- this only guarantees a rating can't be silently absent.
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
|
-
import type { CapabilityContext } from "./capabilities.js";
|
|
17
|
-
|
|
15
|
+
import type { RegistryCapabilityContext } from "./capabilities.js";
|
|
18
16
|
export type AgeRating = "all-ages" | "teen" | "mature" | "unrated";
|
|
19
|
-
|
|
20
17
|
export interface ContentRating {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
rating: AgeRating;
|
|
19
|
+
/** Platform-defined vocabulary, not free text, so parental-control
|
|
20
|
+
* filters can match reliably -- e.g. "violence", "language". */
|
|
21
|
+
descriptors?: string[];
|
|
22
|
+
/** Self-rated by the creator, or confirmed by platform review. A
|
|
23
|
+
* parental-control profile should be able to require "platform-reviewed"
|
|
24
|
+
* specifically, not just any declared rating. */
|
|
25
|
+
source: "creator-declared" | "platform-reviewed";
|
|
29
26
|
}
|
|
30
|
-
|
|
31
27
|
/**
|
|
32
28
|
* The seam between "what a channel produces" and "how a viewer receives
|
|
33
29
|
* it." Text+image, video, and VR are all ContentDeliveryAdapters -- the
|
|
@@ -37,13 +33,12 @@ export interface ContentRating {
|
|
|
37
33
|
* references the client interprets per `contentType`.
|
|
38
34
|
*/
|
|
39
35
|
export interface ContentDescriptor {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
contentType: string;
|
|
37
|
+
payload: unknown;
|
|
38
|
+
mediaRefs?: string[];
|
|
39
|
+
rating: ContentRating;
|
|
44
40
|
}
|
|
45
|
-
|
|
46
41
|
export interface ContentDeliveryAdapter<TContent = unknown, TConfig = unknown> {
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
contentType: string;
|
|
43
|
+
describe(ctx: RegistryCapabilityContext<TConfig>, content: TContent): Promise<ContentDescriptor>;
|
|
49
44
|
}
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content delivery and content rating.
|
|
3
|
+
*
|
|
4
|
+
* `rating` is required, not optional, on every ContentDescriptor --
|
|
5
|
+
* enforced at the type level so a ContentDeliveryAdapter implementation
|
|
6
|
+
* cannot compile without producing one. This is the engineering side of
|
|
7
|
+
* "general audience, possibly including minors": the platform should be
|
|
8
|
+
* structurally incapable of serving unrated content, not just discouraged
|
|
9
|
+
* from it.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT legal guidance. Actual age-verification, parental-consent,
|
|
12
|
+
* and regulatory requirements (COPPA and equivalents) need real legal
|
|
13
|
+
* review -- this only guarantees a rating can't be silently absent.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -8,58 +8,28 @@
|
|
|
8
8
|
* privileged is an explicit, optional upgrade; everything less privileged
|
|
9
9
|
* is an automated demotion (a quota/abuse signal), not a ban.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
11
|
export type TrustLevel = "platform" | "reviewed" | "standard" | "restricted";
|
|
13
|
-
|
|
14
12
|
export interface ResourceQuota {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
maxConcurrentExecutions: number;
|
|
14
|
+
maxExecutionDurationMs: number;
|
|
15
|
+
maxMemoryMb: number;
|
|
16
|
+
/** Rolling 30-day compute budget. The metering/enforcement hook lives in
|
|
17
|
+
* the execution plane (Lambda/Fargate reservations, billing alarms) --
|
|
18
|
+
* this is just the declared ceiling a tenant is told about. */
|
|
19
|
+
maxMonthlyComputeMs: number;
|
|
22
20
|
}
|
|
23
|
-
|
|
24
21
|
export interface TenantContext {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
tenantId: string;
|
|
23
|
+
trustLevel: TrustLevel;
|
|
24
|
+
quota: ResourceQuota;
|
|
28
25
|
}
|
|
29
|
-
|
|
30
26
|
/**
|
|
31
27
|
* Starting points to tune against real usage, not load-bearing numbers.
|
|
32
28
|
* The point being enforced is structural: every tier has an explicit,
|
|
33
29
|
* finite ceiling -- including `platform`, which should get a real quota
|
|
34
30
|
* before this goes to production rather than staying "unlimited."
|
|
35
31
|
*/
|
|
36
|
-
export const DEFAULT_QUOTAS: Record<TrustLevel, ResourceQuota
|
|
37
|
-
platform: {
|
|
38
|
-
maxConcurrentExecutions: 10_000,
|
|
39
|
-
maxExecutionDurationMs: 15 * 60_000,
|
|
40
|
-
maxMemoryMb: 3008,
|
|
41
|
-
maxMonthlyComputeMs: Number.MAX_SAFE_INTEGER,
|
|
42
|
-
},
|
|
43
|
-
reviewed: {
|
|
44
|
-
maxConcurrentExecutions: 200,
|
|
45
|
-
maxExecutionDurationMs: 60_000,
|
|
46
|
-
maxMemoryMb: 1024,
|
|
47
|
-
maxMonthlyComputeMs: 50 * 60 * 60_000, // 50 compute-hours / month
|
|
48
|
-
},
|
|
49
|
-
standard: {
|
|
50
|
-
maxConcurrentExecutions: 10,
|
|
51
|
-
maxExecutionDurationMs: 10_000,
|
|
52
|
-
maxMemoryMb: 256,
|
|
53
|
-
maxMonthlyComputeMs: 2 * 60 * 60_000, // 2 compute-hours / month
|
|
54
|
-
},
|
|
55
|
-
restricted: {
|
|
56
|
-
maxConcurrentExecutions: 1,
|
|
57
|
-
maxExecutionDurationMs: 5_000,
|
|
58
|
-
maxMemoryMb: 128,
|
|
59
|
-
maxMonthlyComputeMs: 10 * 60_000, // 10 compute-minutes / month
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
|
|
32
|
+
export declare const DEFAULT_QUOTAS: Record<TrustLevel, ResourceQuota>;
|
|
63
33
|
/**
|
|
64
34
|
* Every new tenant starts here, automatically, with no human in the loop --
|
|
65
35
|
* this is what makes 100,000 tenants onboardable at all. Upgrades to
|
|
@@ -68,10 +38,4 @@ export const DEFAULT_QUOTAS: Record<TrustLevel, ResourceQuota> = {
|
|
|
68
38
|
* `restricted` are an automated response to abuse/quota-violation signals,
|
|
69
39
|
* also built later. This function only governs the Day 0 default.
|
|
70
40
|
*/
|
|
71
|
-
export function assignInitialTrust(tenantId: string): TenantContext
|
|
72
|
-
return {
|
|
73
|
-
tenantId,
|
|
74
|
-
trustLevel: "standard",
|
|
75
|
-
quota: DEFAULT_QUOTAS.standard,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
41
|
+
export declare function assignInitialTrust(tenantId: string): TenantContext;
|
package/dist/tenancy.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant trust and resource quotas.
|
|
3
|
+
*
|
|
4
|
+
* At a few dozen developers, manually reviewing each one before granting
|
|
5
|
+
* access is a reasonable process. At 100,000 tenants, "manual review" isn't
|
|
6
|
+
* a process -- it's a queue that never empties. `standard` is the only
|
|
7
|
+
* tier a new signup can reach with no human in the loop. Everything more
|
|
8
|
+
* privileged is an explicit, optional upgrade; everything less privileged
|
|
9
|
+
* is an automated demotion (a quota/abuse signal), not a ban.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Starting points to tune against real usage, not load-bearing numbers.
|
|
13
|
+
* The point being enforced is structural: every tier has an explicit,
|
|
14
|
+
* finite ceiling -- including `platform`, which should get a real quota
|
|
15
|
+
* before this goes to production rather than staying "unlimited."
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_QUOTAS = {
|
|
18
|
+
platform: {
|
|
19
|
+
maxConcurrentExecutions: 10_000,
|
|
20
|
+
maxExecutionDurationMs: 15 * 60_000,
|
|
21
|
+
maxMemoryMb: 3008,
|
|
22
|
+
maxMonthlyComputeMs: Number.MAX_SAFE_INTEGER,
|
|
23
|
+
},
|
|
24
|
+
reviewed: {
|
|
25
|
+
maxConcurrentExecutions: 200,
|
|
26
|
+
maxExecutionDurationMs: 60_000,
|
|
27
|
+
maxMemoryMb: 1024,
|
|
28
|
+
maxMonthlyComputeMs: 50 * 60 * 60_000, // 50 compute-hours / month
|
|
29
|
+
},
|
|
30
|
+
standard: {
|
|
31
|
+
maxConcurrentExecutions: 10,
|
|
32
|
+
maxExecutionDurationMs: 10_000,
|
|
33
|
+
maxMemoryMb: 256,
|
|
34
|
+
maxMonthlyComputeMs: 2 * 60 * 60_000, // 2 compute-hours / month
|
|
35
|
+
},
|
|
36
|
+
restricted: {
|
|
37
|
+
maxConcurrentExecutions: 1,
|
|
38
|
+
maxExecutionDurationMs: 5_000,
|
|
39
|
+
maxMemoryMb: 128,
|
|
40
|
+
maxMonthlyComputeMs: 10 * 60_000, // 10 compute-minutes / month
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Every new tenant starts here, automatically, with no human in the loop --
|
|
45
|
+
* this is what makes 100,000 tenants onboardable at all. Upgrades to
|
|
46
|
+
* `reviewed` or `platform` are explicit, out-of-band actions (an admin
|
|
47
|
+
* decision, or an automated reputation system built later); downgrades to
|
|
48
|
+
* `restricted` are an automated response to abuse/quota-violation signals,
|
|
49
|
+
* also built later. This function only governs the Day 0 default.
|
|
50
|
+
*/
|
|
51
|
+
export function assignInitialTrust(tenantId) {
|
|
52
|
+
return {
|
|
53
|
+
tenantId,
|
|
54
|
+
trustLevel: "standard",
|
|
55
|
+
quota: DEFAULT_QUOTAS.standard,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portalshq/contracts",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/portalshq/portals-cloud"
|
|
7
7
|
},
|
|
8
8
|
"description": "Capability contract types and channel manifest schema. The single source of truth every other package validates against.",
|
|
9
|
-
"
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
18
|
+
},
|
|
10
19
|
"dependencies": {
|
|
11
20
|
"zod": "^3.23.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.5.0",
|
|
24
|
+
"@types/node": "^20.0.0"
|
|
12
25
|
}
|
|
13
26
|
}
|
package/src/capabilities.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The capability contract. A way for a unit of platform functionality
|
|
3
|
-
* (chat, polls, lobby presence, a video player, a VR render target, a
|
|
4
|
-
* game-rules engine -- built or not-yet-built) to declare itself, and a
|
|
5
|
-
* way for a channel to compose the capabilities it needs.
|
|
6
|
-
*
|
|
7
|
-
* Design principle enforced here: a platform that silently no-ops an
|
|
8
|
-
* unbuilt or misconfigured capability is more dangerous than one that
|
|
9
|
-
* refuses to resolve. `CapabilityRegistry.resolve()` fails loud, with a
|
|
10
|
-
* specific error per problem, never a silent partial result. The same
|
|
11
|
-
* principle extends to tenancy below: a manifest cannot assert its own
|
|
12
|
-
* trust level -- that comes from a trusted lookup the caller provides,
|
|
13
|
-
* never from the (potentially untrusted) manifest itself.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { TenantContext } from "./tenancy.js";
|
|
17
|
-
|
|
18
|
-
export interface ValidationResult {
|
|
19
|
-
ok: boolean;
|
|
20
|
-
errors?: string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface CapabilityContext<TConfig = unknown> {
|
|
24
|
-
channelId: string;
|
|
25
|
-
tenant: TenantContext;
|
|
26
|
-
config: TConfig;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface CapabilityModule<TConfig = unknown> {
|
|
30
|
-
/** Globally unique, namespaced, e.g. "platform.chat", "platform.video.mux". */
|
|
31
|
-
id: string;
|
|
32
|
-
/** Semver. Used for compatibility checks once external developers ship modules. */
|
|
33
|
-
version: string;
|
|
34
|
-
displayName: string;
|
|
35
|
-
/** Other capability ids this one requires to function, resolved per-channel. */
|
|
36
|
-
dependsOn?: string[];
|
|
37
|
-
/**
|
|
38
|
-
* Validate a channel's config for this capability. Must not throw for
|
|
39
|
-
* ordinary invalid input -- return a ValidationResult so the registry can
|
|
40
|
-
* collect every problem in one pass instead of failing on the first.
|
|
41
|
-
*/
|
|
42
|
-
validateConfig(config: unknown): ValidationResult;
|
|
43
|
-
/** Called once when a channel using this capability is first resolved. */
|
|
44
|
-
onChannelRegistered?(ctx: CapabilityContext<TConfig>): Promise<void>;
|
|
45
|
-
/** Called when the channel is torn down or the capability is removed. */
|
|
46
|
-
onChannelUnregistered?(ctx: CapabilityContext<TConfig>): Promise<void>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Channel manifests ────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
export interface CapabilityUsage {
|
|
52
|
-
capabilityId: string;
|
|
53
|
-
config: unknown;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface ChannelManifest {
|
|
57
|
-
channelId: string;
|
|
58
|
-
/** Declared owner. Verified against the trusted TenantContext passed into
|
|
59
|
-
* `resolve()`, not taken on faith -- see the mismatch check below. */
|
|
60
|
-
tenantId: string;
|
|
61
|
-
displayName: string;
|
|
62
|
-
capabilities: CapabilityUsage[];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface ResolvedCapability {
|
|
66
|
-
module: CapabilityModule;
|
|
67
|
-
config: unknown;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export type ResolvedChannel =
|
|
71
|
-
| { ok: true; channelId: string; tenant: TenantContext; capabilities: ResolvedCapability[] }
|
|
72
|
-
| { ok: false; channelId: string; errors: string[] };
|
|
73
|
-
|
|
74
|
-
// ── Registry ─────────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
export class CapabilityRegistry {
|
|
77
|
-
private modules = new Map<string, CapabilityModule>();
|
|
78
|
-
|
|
79
|
-
register(module: CapabilityModule): void {
|
|
80
|
-
if (this.modules.has(module.id)) {
|
|
81
|
-
throw new Error(`Capability "${module.id}" is already registered.`);
|
|
82
|
-
}
|
|
83
|
-
this.modules.set(module.id, module);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
get(id: string): CapabilityModule | undefined {
|
|
87
|
-
return this.modules.get(id);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
has(id: string): boolean {
|
|
91
|
-
return this.modules.has(id);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
list(): CapabilityModule[] {
|
|
95
|
-
return [...this.modules.values()];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Resolves a manifest against currently-registered modules, for a given
|
|
100
|
-
* (trustworthily-sourced) tenant. Collects EVERY problem in a single pass
|
|
101
|
-
* rather than stopping at the first, so a developer sees the whole
|
|
102
|
-
* picture at once. `tenant` must come from your own tenant lookup (e.g.
|
|
103
|
-
* the control plane's database), never from the manifest itself -- a
|
|
104
|
-
* manifest declaring its own trust level would let any tenant grant
|
|
105
|
-
* themselves unlimited quota.
|
|
106
|
-
*/
|
|
107
|
-
resolve(manifest: ChannelManifest, tenant: TenantContext): ResolvedChannel {
|
|
108
|
-
if (tenant.tenantId !== manifest.tenantId) {
|
|
109
|
-
return {
|
|
110
|
-
ok: false,
|
|
111
|
-
channelId: manifest.channelId,
|
|
112
|
-
errors: [
|
|
113
|
-
`Tenant mismatch: manifest declares tenant "${manifest.tenantId}" but ` +
|
|
114
|
-
`resolution was requested for tenant "${tenant.tenantId}".`,
|
|
115
|
-
],
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const errors: string[] = [];
|
|
120
|
-
const resolved: ResolvedCapability[] = [];
|
|
121
|
-
const declaredIds = new Set(manifest.capabilities.map((u) => u.capabilityId));
|
|
122
|
-
|
|
123
|
-
for (const usage of manifest.capabilities) {
|
|
124
|
-
const module = this.modules.get(usage.capabilityId);
|
|
125
|
-
if (!module) {
|
|
126
|
-
errors.push(
|
|
127
|
-
`Channel "${manifest.channelId}" declares capability "${usage.capabilityId}", ` +
|
|
128
|
-
`which is not registered on this platform instance.`,
|
|
129
|
-
);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
for (const dep of module.dependsOn ?? []) {
|
|
134
|
-
if (!declaredIds.has(dep)) {
|
|
135
|
-
errors.push(
|
|
136
|
-
`Capability "${module.id}" requires "${dep}", which channel ` +
|
|
137
|
-
`"${manifest.channelId}" does not declare.`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const validation = module.validateConfig(usage.config);
|
|
143
|
-
if (!validation.ok) {
|
|
144
|
-
const detail = validation.errors?.join("; ") ?? "invalid config";
|
|
145
|
-
errors.push(`Invalid config for capability "${module.id}": ${detail}`);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
resolved.push({ module, config: usage.config });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (errors.length > 0) {
|
|
153
|
-
return { ok: false, channelId: manifest.channelId, errors };
|
|
154
|
-
}
|
|
155
|
-
return { ok: true, channelId: manifest.channelId, tenant, capabilities: resolved };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Runs onChannelRegistered for every resolved capability, in declaration order. */
|
|
159
|
-
async activate(resolved: Extract<ResolvedChannel, { ok: true }>): Promise<void> {
|
|
160
|
-
for (const { module, config } of resolved.capabilities) {
|
|
161
|
-
await module.onChannelRegistered?.({
|
|
162
|
-
channelId: resolved.channelId,
|
|
163
|
-
tenant: resolved.tenant,
|
|
164
|
-
config,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Runs onChannelUnregistered for every resolved capability, in reverse order. */
|
|
170
|
-
async deactivate(resolved: Extract<ResolvedChannel, { ok: true }>): Promise<void> {
|
|
171
|
-
for (const { module, config } of [...resolved.capabilities].reverse()) {
|
|
172
|
-
await module.onChannelUnregistered?.({
|
|
173
|
-
channelId: resolved.channelId,
|
|
174
|
-
tenant: resolved.tenant,
|
|
175
|
-
config,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A Capability is the smallest unit of composition in the platform.
|
|
5
|
-
* Every capability — realtime-fanout, video-delivery, identity, the
|
|
6
|
-
* narrative-engine-adapter, everything — implements this same contract
|
|
7
|
-
* shape. The runtime never calls a capability directly; it always goes
|
|
8
|
-
* through the registry, which validates against this schema first.
|
|
9
|
-
*
|
|
10
|
-
* Modeled deliberately on Kubernetes CRD + controller semantics, not on
|
|
11
|
-
* a bare RPC interface: declare a resource shape, let the runtime
|
|
12
|
-
* reconcile it. See docs/architecture-decision-records for rationale.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const PermissionScopeSchema = z.object({
|
|
16
|
-
resource: z.string(), // e.g. "world:write", "audience:read"
|
|
17
|
-
effect: z.enum(["allow", "deny"]),
|
|
18
|
-
});
|
|
19
|
-
export type PermissionScope = z.infer<typeof PermissionScopeSchema>;
|
|
20
|
-
|
|
21
|
-
export const CapabilityContractSchema = z.object({
|
|
22
|
-
id: z.string(), // e.g. "realtime-fanout"
|
|
23
|
-
version: z.string(), // semver
|
|
24
|
-
inputSchema: z.record(z.unknown()), // JSON schema for capability input
|
|
25
|
-
outputSchema: z.record(z.unknown()), // JSON schema for capability output
|
|
26
|
-
permissions: z.array(PermissionScopeSchema),
|
|
27
|
-
plane: z.enum(["control", "data"]), // which plane this capability runs in — see ADR 0001
|
|
28
|
-
lazyStart: z.boolean().default(true), // billed only while an invoking session is active
|
|
29
|
-
});
|
|
30
|
-
export type CapabilityContract = z.infer<typeof CapabilityContractSchema>;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Implemented by every capability package. The registry calls `invoke`
|
|
34
|
-
* after validating `input` against the contract's inputSchema. Capabilities
|
|
35
|
-
* never call each other directly — composition happens at the channel
|
|
36
|
-
* manifest level, mediated by the runtime.
|
|
37
|
-
*/
|
|
38
|
-
export interface Capability<TInput = unknown, TOutput = unknown> {
|
|
39
|
-
contract: CapabilityContract;
|
|
40
|
-
invoke(input: TInput, ctx: CapabilityContext): Promise<TOutput>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface CapabilityContext {
|
|
44
|
-
sessionId: string;
|
|
45
|
-
channelId: string;
|
|
46
|
-
worldId: string;
|
|
47
|
-
/** Resolves a NAP address to a narrative/world resource. See @portalshq/resolver. */
|
|
48
|
-
resolve: (napAddress: string) => Promise<unknown>;
|
|
49
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A Channel is a declarative composition of capabilities + a world template.
|
|
5
|
-
* Developers write one of these instead of writing infrastructure. This is
|
|
6
|
-
* the file the SDK CLI reads, validates, and deploys.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately format-agnostic: a branching-film channel, a live game-show
|
|
9
|
-
* channel, and a text/image channel are all just different capability
|
|
10
|
-
* selections against the same manifest shape — there is no separate
|
|
11
|
-
* "channel type" concept in the runtime.
|
|
12
|
-
*/
|
|
13
|
-
export const CapabilityRefSchema = z.object({
|
|
14
|
-
capabilityId: z.string(), // must match a CapabilityContract.id in the registry
|
|
15
|
-
version: z.string().optional(), // pin a version; defaults to latest compatible
|
|
16
|
-
config: z.record(z.unknown()).optional(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const ChannelManifestSchema = z.object({
|
|
20
|
-
apiVersion: z.literal("nap/v1"),
|
|
21
|
-
kind: z.literal("Channel"),
|
|
22
|
-
metadata: z.object({
|
|
23
|
-
name: z.string(),
|
|
24
|
-
owner: z.string(), // provider/developer id
|
|
25
|
-
description: z.string().optional(),
|
|
26
|
-
}),
|
|
27
|
-
spec: z.object({
|
|
28
|
-
worldTemplate: z.string(), // NAP address of the world template
|
|
29
|
-
narrativeRef: z.string().optional(), // NAP address, if this channel uses narrative state
|
|
30
|
-
capabilities: z.array(CapabilityRefSchema).min(1),
|
|
31
|
-
visibility: z.enum(["public", "unlisted", "private"]).default("public"),
|
|
32
|
-
}),
|
|
33
|
-
});
|
|
34
|
-
export type ChannelManifest = z.infer<typeof ChannelManifestSchema>;
|