@mjczone/realtime-contract 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MJCZone, Inc.
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.
@@ -0,0 +1,25 @@
1
+ export type Tier = 'live' | 'replay' | 'presence';
2
+ export interface ParsedChannel {
3
+ tier: Tier;
4
+ /** The tenant ID, or `global` for global channels. */
5
+ tenant: string;
6
+ topic: string;
7
+ id?: string;
8
+ userId?: string;
9
+ }
10
+ export declare const MAX_CHANNEL_LENGTH = 255;
11
+ export declare const RESERVED_TOPICS: readonly ['all', 'grp', 'user', 'run'];
12
+ export declare function isValidTenant(tenant: string): boolean;
13
+ export declare function isValidUserId(userId: string): boolean;
14
+ export declare function tryParseChannel(name: string): ParsedChannel | null;
15
+ export declare function parseChannel(name: string): ParsedChannel;
16
+ export declare function isValidChannel(name: string): boolean;
17
+ export declare const Channels: {
18
+ global: (topic: string, id?: string, tier?: Tier) => string;
19
+ tenantAll: (tenant: string, tier?: Tier) => string;
20
+ record: (tenant: string, topic: string, id?: string, tier?: Tier) => string;
21
+ group: (tenant: string, group: string, tier?: Tier) => string;
22
+ user: (tenant: string, userId: string, tier?: Tier) => string;
23
+ run: (tenant: string, runId: string) => string;
24
+ presence: (tenant: string, topic: string, id?: string) => string;
25
+ };
@@ -0,0 +1,99 @@
1
+ export const MAX_CHANNEL_LENGTH = 255;
2
+ export const RESERVED_TOPICS = ['all', 'grp', 'user', 'run'];
3
+ const SEGMENT = /^[A-Za-z0-9_-]+$/;
4
+ const TENANT = /^[A-Za-z0-9_-]{1,64}$/;
5
+ const USER_ID = /^[A-Za-z0-9_-]{1,128}$/;
6
+ const TIERS = ['live', 'replay', 'presence'];
7
+ export function isValidTenant(tenant) {
8
+ return TENANT.test(tenant) && tenant !== 'global';
9
+ }
10
+ export function isValidUserId(userId) {
11
+ return USER_ID.test(userId);
12
+ }
13
+ export function tryParseChannel(name) {
14
+ if (typeof name !== 'string' || name.length > MAX_CHANNEL_LENGTH)
15
+ return null;
16
+ const colon = name.indexOf(':');
17
+ const tier = name.slice(0, colon);
18
+ if (colon < 0 || !TIERS.includes(tier))
19
+ return null;
20
+ const rest = name.slice(colon + 1);
21
+ if (rest.startsWith('global.')) {
22
+ if (tier === 'presence')
23
+ return null;
24
+ const parts = rest.slice('global.'.length).split('.');
25
+ if (parts.length > 2 || !parts.every((p) => SEGMENT.test(p)))
26
+ return null;
27
+ const [topic, id] = parts;
28
+ if (RESERVED_TOPICS.includes(topic))
29
+ return null;
30
+ return withId({ tier: tier, tenant: 'global', topic }, id);
31
+ }
32
+ if (!rest.startsWith('t-'))
33
+ return null;
34
+ const dot = rest.indexOf('.');
35
+ if (dot < 0)
36
+ return null;
37
+ const tenant = rest.slice(2, dot);
38
+ if (!isValidTenant(tenant))
39
+ return null;
40
+ const tail = rest.slice(dot + 1);
41
+ if (tail.startsWith('user#')) {
42
+ const userId = tail.slice('user#'.length);
43
+ if (tier === 'presence' || !isValidUserId(userId))
44
+ return null;
45
+ return { tier: tier, tenant, topic: 'user', userId };
46
+ }
47
+ const parts = tail.split('.');
48
+ if (parts.length > 2 || !parts.every((p) => SEGMENT.test(p)))
49
+ return null;
50
+ const [topic, id] = parts;
51
+ switch (topic) {
52
+ case 'all':
53
+ if (id !== undefined || tier === 'presence')
54
+ return null;
55
+ break;
56
+ case 'grp':
57
+ case 'run':
58
+ if (id === undefined)
59
+ return null;
60
+ break;
61
+ case 'user':
62
+ return null;
63
+ }
64
+ return withId({ tier: tier, tenant, topic }, id);
65
+ }
66
+ function withId(c, id) {
67
+ if (id !== undefined)
68
+ c.id = id;
69
+ return c;
70
+ }
71
+ export function parseChannel(name) {
72
+ const c = tryParseChannel(name);
73
+ if (!c)
74
+ throw new Error(`Invalid channel name: ${JSON.stringify(name)}`);
75
+ return c;
76
+ }
77
+ export function isValidChannel(name) {
78
+ return tryParseChannel(name) !== null;
79
+ }
80
+ function build(name, want) {
81
+ const c = parseChannel(name);
82
+ if (c.tenant !== want.tenant || c.topic !== want.topic || c.id !== want.id || c.userId !== want.userId) {
83
+ throw new Error(`Invalid channel parts: ${JSON.stringify(want)}`);
84
+ }
85
+ return name;
86
+ }
87
+ function suffix(id) {
88
+ return id === undefined ? '' : `.${id}`;
89
+ }
90
+ export const Channels = {
91
+ global: (topic, id, tier = 'live') => build(`${tier}:global.${topic}${suffix(id)}`, { tenant: 'global', topic, id }),
92
+ tenantAll: (tenant, tier = 'live') => build(`${tier}:t-${tenant}.all`, { tenant, topic: 'all' }),
93
+ record: (tenant, topic, id, tier = 'live') => build(`${tier}:t-${tenant}.${topic}${suffix(id)}`, { tenant, topic, id }),
94
+ group: (tenant, group, tier = 'live') => build(`${tier}:t-${tenant}.grp.${group}`, { tenant, topic: 'grp', id: group }),
95
+ user: (tenant, userId, tier = 'live') => build(`${tier}:t-${tenant}.user#${userId}`, { tenant, topic: 'user', userId }),
96
+ run: (tenant, runId) => build(`replay:t-${tenant}.run.${runId}`, { tenant, topic: 'run', id: runId }),
97
+ presence: (tenant, topic, id) => build(`presence:t-${tenant}.${topic}${suffix(id)}`, { tenant, topic, id }),
98
+ };
99
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ export interface Envelope<T extends object = Record<string, unknown>> {
2
+ v: 1;
3
+ id: string;
4
+ type: string;
5
+ tenant: string;
6
+ source: string;
7
+ ts: string;
8
+ corr?: string;
9
+ data: T;
10
+ }
11
+ export interface EnvelopeInput<T extends object = Record<string, unknown>> {
12
+ type: string;
13
+ tenant: string;
14
+ source: string;
15
+ corr?: string;
16
+ data: T;
17
+ }
18
+ export declare const MAX_ENVELOPE_BYTES = 65536;
19
+ export declare const RESERVED_FAMILIES: readonly ['agui', 'job', 'entity', 'notice', 'system'];
20
+ export declare function newId(): string;
21
+ export declare function createEnvelope<T extends object>(input: EnvelopeInput<T>): Envelope<T>;
22
+ /** Returns why the value is not a valid envelope, or null when it is. Unknown fields are ignored. */
23
+ export declare function validateEnvelope(x: unknown): string | null;
24
+ export declare function isEnvelope(x: unknown): x is Envelope;
25
+ /** Size of the serialized envelope in UTF-8 bytes. */
26
+ export declare function envelopeBytes(env: Envelope<object>): number;
@@ -0,0 +1,66 @@
1
+ import { monotonicFactory } from 'ulid';
2
+ export const MAX_ENVELOPE_BYTES = 65536;
3
+ export const RESERVED_FAMILIES = ['agui', 'job', 'entity', 'notice', 'system'];
4
+ const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
5
+ const TYPE = /^[a-z][a-z0-9]*\.[A-Za-z0-9_.]+$/;
6
+ const TENANT = /^[A-Za-z0-9_-]{1,64}$/;
7
+ const SOURCE = /^[A-Za-z0-9_.-]{1,128}$/;
8
+ const TS = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$/;
9
+ const nextUlid = monotonicFactory();
10
+ export function newId() {
11
+ return nextUlid();
12
+ }
13
+ export function createEnvelope(input) {
14
+ const env = {
15
+ v: 1,
16
+ id: newId(),
17
+ type: input.type,
18
+ tenant: input.tenant,
19
+ source: input.source,
20
+ ts: new Date().toISOString(),
21
+ data: input.data,
22
+ };
23
+ if (input.corr !== undefined)
24
+ env.corr = input.corr;
25
+ const problem = validateEnvelope(env);
26
+ if (problem)
27
+ throw new Error(`Invalid envelope: ${problem}`);
28
+ return env;
29
+ }
30
+ function isPlainObject(x) {
31
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
32
+ }
33
+ function matches(x, re) {
34
+ return typeof x === 'string' && re.test(x);
35
+ }
36
+ /** Returns why the value is not a valid envelope, or null when it is. Unknown fields are ignored. */
37
+ export function validateEnvelope(x) {
38
+ if (!isPlainObject(x))
39
+ return 'not an object';
40
+ if (x.v !== 1)
41
+ return 'v must be 1';
42
+ if (!matches(x.id, ULID))
43
+ return 'id must be a ULID';
44
+ if (!matches(x.type, TYPE) || x.type.length > 128)
45
+ return 'type must be <family>.<name>';
46
+ if (!matches(x.tenant, TENANT))
47
+ return 'tenant is invalid';
48
+ if (!matches(x.source, SOURCE))
49
+ return 'source is invalid';
50
+ if (!matches(x.ts, TS))
51
+ return 'ts must be RFC 3339 UTC';
52
+ if (x.corr !== undefined && (typeof x.corr !== 'string' || x.corr.length < 1 || x.corr.length > 256)) {
53
+ return 'corr must be a string of 1 to 256 characters';
54
+ }
55
+ if (!isPlainObject(x.data))
56
+ return 'data must be an object';
57
+ return null;
58
+ }
59
+ export function isEnvelope(x) {
60
+ return validateEnvelope(x) === null;
61
+ }
62
+ /** Size of the serialized envelope in UTF-8 bytes. */
63
+ export function envelopeBytes(env) {
64
+ return new TextEncoder().encode(JSON.stringify(env)).length;
65
+ }
66
+ //# sourceMappingURL=envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.js","sourceRoot":"","sources":["../src/envelope.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ export interface TenantContext {
2
+ tenant?: string;
3
+ allowGlobal?: boolean;
4
+ }
5
+ export type GuardReason = 'invalid_channel' | 'global_not_allowed' | 'no_tenant_context' | 'tenant_mismatch' | 'envelope_tenant_mismatch';
6
+ export type GuardResult = {
7
+ allow: true;
8
+ } | {
9
+ allow: false;
10
+ reason: GuardReason;
11
+ };
12
+ export declare class TenantGuardError extends Error {
13
+ readonly reason: GuardReason;
14
+ readonly channel: string;
15
+ constructor(reason: GuardReason, channel: string);
16
+ }
17
+ export declare function checkTenant(context: TenantContext, channel: string, envelope: {
18
+ tenant: string;
19
+ }): GuardResult;
20
+ export declare function assertTenant(context: TenantContext, channel: string, envelope: {
21
+ tenant: string;
22
+ }): void;
package/dist/guard.js ADDED
@@ -0,0 +1,35 @@
1
+ import { tryParseChannel } from './channel.js';
2
+ export class TenantGuardError extends Error {
3
+ reason;
4
+ channel;
5
+ constructor(reason, channel) {
6
+ super(`Tenant guard denied publish to ${channel}: ${reason}`);
7
+ this.reason = reason;
8
+ this.channel = channel;
9
+ this.name = 'TenantGuardError';
10
+ }
11
+ }
12
+ export function checkTenant(context, channel, envelope) {
13
+ const c = tryParseChannel(channel);
14
+ if (!c)
15
+ return { allow: false, reason: 'invalid_channel' };
16
+ if (c.tenant === 'global') {
17
+ if (context.allowGlobal !== true)
18
+ return { allow: false, reason: 'global_not_allowed' };
19
+ }
20
+ else {
21
+ if (!context.tenant)
22
+ return { allow: false, reason: 'no_tenant_context' };
23
+ if (context.tenant !== c.tenant)
24
+ return { allow: false, reason: 'tenant_mismatch' };
25
+ }
26
+ if (envelope.tenant !== c.tenant)
27
+ return { allow: false, reason: 'envelope_tenant_mismatch' };
28
+ return { allow: true };
29
+ }
30
+ export function assertTenant(context, channel, envelope) {
31
+ const r = checkTenant(context, channel, envelope);
32
+ if (!r.allow)
33
+ throw new TenantGuardError(r.reason, channel);
34
+ }
35
+ //# sourceMappingURL=guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAgB/C,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAE9B,MAAM;IACN,OAAO;IAFlB,YACW,MAAmB,EACnB,OAAe;QAExB,KAAK,CAAC,kCAAkC,OAAO,KAAK,MAAM,EAAE,CAAC,CAAC;sBAHrD,MAAM;uBACN,OAAO;QAGhB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACjC,CAAC;CACF;AAED,MAAM,UAAU,WAAW,CAAC,OAAsB,EAAE,OAAe,EAAE,QAA4B;IAC/F,MAAM,CAAC,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,CAAC,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC3D,IAAI,CAAC,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC1B,IAAI,OAAO,CAAC,WAAW,KAAK,IAAI;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC;IAC1F,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,OAAO,CAAC,MAAM;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;QAC1E,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IACtF,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,EAAE,CAAC;IAC9F,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAsB,EAAE,OAAe,EAAE,QAA4B;IAChG,MAAM,CAAC,GAAG,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAClD,IAAI,CAAC,CAAC,CAAC,KAAK;QAAE,MAAM,IAAI,gBAAgB,CAAC,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC"}
package/dist/id.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare const MAX_ENCODED_ID_LENGTH = 128;
2
+ /** Encodes any key as a channel ID: A-Z, a-z, 0-9 and - pass through; every other UTF-8 byte becomes _XX. */
3
+ export declare function encodeId(key: string): string;
4
+ /** The reverse of encodeId. Throws on anything encodeId would not produce. */
5
+ export declare function decodeId(id: string): string;
package/dist/id.js ADDED
@@ -0,0 +1,58 @@
1
+ export const MAX_ENCODED_ID_LENGTH = 128;
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder('utf-8', { fatal: true });
4
+ function isPlain(byte) {
5
+ return ((byte >= 0x30 && byte <= 0x39) || // 0-9
6
+ (byte >= 0x41 && byte <= 0x5a) || // A-Z
7
+ (byte >= 0x61 && byte <= 0x7a) || // a-z
8
+ byte === 0x2d // -
9
+ );
10
+ }
11
+ /** Encodes any key as a channel ID: A-Z, a-z, 0-9 and - pass through; every other UTF-8 byte becomes _XX. */
12
+ export function encodeId(key) {
13
+ if (typeof key !== 'string' || key === '')
14
+ throw new Error('encodeId: key must be a non-empty string');
15
+ if (!key.isWellFormed())
16
+ throw new Error('encodeId: key is not valid Unicode text');
17
+ let out = '';
18
+ for (const b of encoder.encode(key)) {
19
+ out += isPlain(b) ? String.fromCharCode(b) : `_${b.toString(16).toUpperCase().padStart(2, '0')}`;
20
+ }
21
+ if (out.length > MAX_ENCODED_ID_LENGTH) {
22
+ throw new Error(`encodeId: encoded ID is ${out.length} characters, over ${MAX_ENCODED_ID_LENGTH}`);
23
+ }
24
+ return out;
25
+ }
26
+ /** The reverse of encodeId. Throws on anything encodeId would not produce. */
27
+ export function decodeId(id) {
28
+ if (typeof id !== 'string' || id === '' || id.length > MAX_ENCODED_ID_LENGTH) {
29
+ throw new Error('decodeId: not an encoded ID');
30
+ }
31
+ const bytes = [];
32
+ for (let i = 0; i < id.length; i++) {
33
+ const c = id.charCodeAt(i);
34
+ if (c === 0x5f) {
35
+ const hex = id.slice(i + 1, i + 3);
36
+ if (!/^[0-9A-F]{2}$/.test(hex))
37
+ throw new Error(`decodeId: bad escape at ${i}`);
38
+ const b = parseInt(hex, 16);
39
+ if (isPlain(b))
40
+ throw new Error(`decodeId: escaped plain character at ${i}`);
41
+ bytes.push(b);
42
+ i += 2;
43
+ }
44
+ else if (isPlain(c)) {
45
+ bytes.push(c);
46
+ }
47
+ else {
48
+ throw new Error(`decodeId: unexpected character at ${i}`);
49
+ }
50
+ }
51
+ try {
52
+ return decoder.decode(new Uint8Array(bytes));
53
+ }
54
+ catch {
55
+ throw new Error('decodeId: not valid UTF-8');
56
+ }
57
+ }
58
+ //# sourceMappingURL=id.js.map
package/dist/id.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.js","sourceRoot":"","sources":["../src/id.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export * from './channel.js';
2
+ export * from './envelope.js';
3
+ export * from './guard.js';
4
+ export * from './id.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './channel.js';
2
+ export * from './envelope.js';
3
+ export * from './guard.js';
4
+ export * from './id.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@mjczone/realtime-contract",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Channel grammar, envelope and tenant guard for the realtime backbone",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mjczone/realtime-backbone.git",
9
+ "directory": "ts/contract"
10
+ },
11
+ "homepage": "https://github.com/mjczone/realtime-backbone#readme",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "dependencies": {
24
+ "ulid": "3.0.2"
25
+ },
26
+ "devDependencies": {
27
+ "ajv": "^8.17.1"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "test": "vitest run"
32
+ }
6
33
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.