@portalshq/contracts 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @portalshq/contracts
2
+
3
+ The schema layer every other package depends on. Nothing here has runtime
4
+ behavior — it's types and zod schemas only, so it can be imported by the
5
+ registry, the runtime, every capability package, and the SDK without any
6
+ of them depending on each other.
7
+
8
+ If you're adding a new capability type, start here: define what its input/
9
+ output/permissions look like before writing the implementation package.
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@portalshq/contracts",
3
+ "version": "0.0.2",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/portalshq/portals-cloud"
7
+ },
8
+ "description": "Capability contract types and channel manifest schema. The single source of truth every other package validates against.",
9
+ "main": "src/index.ts",
10
+ "dependencies": {
11
+ "zod": "^3.23.0"
12
+ }
13
+ }
@@ -0,0 +1,179 @@
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
+ }
@@ -0,0 +1,49 @@
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
+ }
@@ -0,0 +1,34 @@
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>;
package/src/content.ts ADDED
@@ -0,0 +1,49 @@
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
+
16
+ import type { CapabilityContext } from "./capabilities.js";
17
+
18
+ export type AgeRating = "all-ages" | "teen" | "mature" | "unrated";
19
+
20
+ export interface ContentRating {
21
+ rating: AgeRating;
22
+ /** Platform-defined vocabulary, not free text, so parental-control
23
+ * filters can match reliably -- e.g. "violence", "language". */
24
+ descriptors?: string[];
25
+ /** Self-rated by the creator, or confirmed by platform review. A
26
+ * parental-control profile should be able to require "platform-reviewed"
27
+ * specifically, not just any declared rating. */
28
+ source: "creator-declared" | "platform-reviewed";
29
+ }
30
+
31
+ /**
32
+ * The seam between "what a channel produces" and "how a viewer receives
33
+ * it." Text+image, video, and VR are all ContentDeliveryAdapters -- the
34
+ * platform core never special-cases any one medium. `describe` returns a
35
+ * generic envelope; it's deliberately NOT "render" -- this layer doesn't
36
+ * assume a server-side rendering model, just a typed payload + media
37
+ * references the client interprets per `contentType`.
38
+ */
39
+ export interface ContentDescriptor {
40
+ contentType: string;
41
+ payload: unknown;
42
+ mediaRefs?: string[];
43
+ rating: ContentRating;
44
+ }
45
+
46
+ export interface ContentDeliveryAdapter<TContent = unknown, TConfig = unknown> {
47
+ contentType: string;
48
+ describe(ctx: CapabilityContext<TConfig>, content: TContent): Promise<ContentDescriptor>;
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./capability-contract";
2
+ export * from "./channel-manifest.schema";
3
+ export * from "./capabilities";
4
+ export * from "./tenancy";
5
+ export * from "./content";
package/src/tenancy.ts ADDED
@@ -0,0 +1,77 @@
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
+ export type TrustLevel = "platform" | "reviewed" | "standard" | "restricted";
13
+
14
+ export interface ResourceQuota {
15
+ maxConcurrentExecutions: number;
16
+ maxExecutionDurationMs: number;
17
+ maxMemoryMb: number;
18
+ /** Rolling 30-day compute budget. The metering/enforcement hook lives in
19
+ * the execution plane (Lambda/Fargate reservations, billing alarms) --
20
+ * this is just the declared ceiling a tenant is told about. */
21
+ maxMonthlyComputeMs: number;
22
+ }
23
+
24
+ export interface TenantContext {
25
+ tenantId: string;
26
+ trustLevel: TrustLevel;
27
+ quota: ResourceQuota;
28
+ }
29
+
30
+ /**
31
+ * Starting points to tune against real usage, not load-bearing numbers.
32
+ * The point being enforced is structural: every tier has an explicit,
33
+ * finite ceiling -- including `platform`, which should get a real quota
34
+ * before this goes to production rather than staying "unlimited."
35
+ */
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
+
63
+ /**
64
+ * Every new tenant starts here, automatically, with no human in the loop --
65
+ * this is what makes 100,000 tenants onboardable at all. Upgrades to
66
+ * `reviewed` or `platform` are explicit, out-of-band actions (an admin
67
+ * decision, or an automated reputation system built later); downgrades to
68
+ * `restricted` are an automated response to abuse/quota-violation signals,
69
+ * also built later. This function only governs the Day 0 default.
70
+ */
71
+ export function assignInitialTrust(tenantId: string): TenantContext {
72
+ return {
73
+ tenantId,
74
+ trustLevel: "standard",
75
+ quota: DEFAULT_QUOTAS.standard,
76
+ };
77
+ }