@canonmsg/backend-contracts 5.0.0 → 5.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/README.md CHANGED
@@ -34,6 +34,8 @@ The JSON Schemas are also emitted as files for non-JavaScript consumers: `@canon
34
34
 
35
35
  **Behavior policy and contact requests.** The participation evaluator the stream service runs before dispatching a turn, and the contact-request serializer both sides validate against.
36
36
 
37
+ **Server-side normalizers.** `readCanonicalPolicy` resolves a stored `inboundPolicy` / `groupJoinPolicy` field to the access triplet's three values, defaulting to `approval-required` rather than widening to `open`. `serializeSelfContext` is the agent-facing projection of a stored self-context. `readModerationStatus` is the single definition of an ejected account. All take plain document-shaped data — the package still performs no I/O.
38
+
37
39
  **Environments.** `CANON_ENVIRONMENT_CONTRACTS` binds `canon-dev-v1` and `canon-prod-v1` to their project and region; `getCanonEnvironmentContract` rejects anything else rather than defaulting.
38
40
 
39
41
  **Diff redaction.** Approval diffs carry pre-image context into a message every conversation member can read, so secret-shaped paths and tokens are suppressed before send — by the emitting host, and again defensively by the server.
@@ -0,0 +1,9 @@
1
+ /** The canonical access triplet's policy values (`inboundPolicy`, `groupJoinPolicy`). */
2
+ export type CanonicalPolicy = 'open' | 'approval-required' | 'owner-only';
3
+ /**
4
+ * Read a stored policy field, defaulting to the value Canon assigns on account
5
+ * creation. Anything unrecognized — missing, misspelled, or the retired
6
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
7
+ * 'open'.
8
+ */
9
+ export declare function readCanonicalPolicy(value: unknown): CanonicalPolicy;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read a stored policy field, defaulting to the value Canon assigns on account
3
+ * creation. Anything unrecognized — missing, misspelled, or the retired
4
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
5
+ * 'open'.
6
+ */
7
+ export function readCanonicalPolicy(value) {
8
+ if (value === 'open' || value === 'approval-required' || value === 'owner-only') {
9
+ return value;
10
+ }
11
+ return 'approval-required';
12
+ }
@@ -1,4 +1,11 @@
1
1
  export type ParticipationStyle = 'natural' | 'collaborative' | 'mention-first' | 'approval-gated' | 'handoff-only' | 'observer';
2
+ /**
3
+ * The stored/parsed shape. This is the RETURN type of
4
+ * {@link parseAgentBehaviorSettings} and {@link normalizeStoredAgentBehaviorPolicy},
5
+ * so its optional fields deliberately stay `T | undefined` — widening them to
6
+ * `T | null` would be a breaking change for every reader of those results.
7
+ * Parameter positions take {@link AgentBehaviorSettingsInput} instead.
8
+ */
2
9
  export interface AgentBehaviorSettingsRecord {
3
10
  participationStyle?: ParticipationStyle;
4
11
  allowAgentToAgent?: boolean;
@@ -7,15 +14,30 @@ export interface AgentBehaviorSettingsRecord {
7
14
  maxConsecutiveAgentTurns?: number | null;
8
15
  instructions?: string | null;
9
16
  }
17
+ /**
18
+ * The input shape accepted by {@link resolveAgentBehaviorPolicy}. Every field
19
+ * additionally accepts an explicit `null` (an editor clearing a value), which
20
+ * coalesces exactly like `undefined` — the field falls through to the next
21
+ * layer. {@link AgentBehaviorSettingsRecord} is assignable to this type.
22
+ */
23
+ export interface AgentBehaviorSettingsInput {
24
+ participationStyle?: ParticipationStyle | null;
25
+ allowAgentToAgent?: boolean | null;
26
+ allowLongRunningCollaboration?: boolean | null;
27
+ requireMentionForGroupReplies?: boolean | null;
28
+ maxConsecutiveAgentTurns?: number | null;
29
+ instructions?: string | null;
30
+ }
31
+ export interface ParticipationPolicy {
32
+ style: ParticipationStyle;
33
+ allowAgentToAgent: boolean;
34
+ allowHumanToAgent: boolean;
35
+ allowLongRunningCollaboration: boolean;
36
+ requireMentionForGroupReplies: boolean;
37
+ maxConsecutiveAgentTurns?: number | null;
38
+ }
10
39
  export interface ResolvedAgentBehaviorPolicyRecord {
11
- participation: {
12
- style: ParticipationStyle;
13
- allowAgentToAgent: boolean;
14
- allowHumanToAgent: boolean;
15
- allowLongRunningCollaboration: boolean;
16
- requireMentionForGroupReplies: boolean;
17
- maxConsecutiveAgentTurns?: number | null;
18
- };
40
+ participation: ParticipationPolicy;
19
41
  instructions: string[];
20
42
  source: {
21
43
  hasAgentDefault: boolean;
@@ -31,7 +53,13 @@ export interface ParticipationDecisionInput {
31
53
  consecutiveAgentTurns?: number;
32
54
  currentAgentStreakStartedByHuman?: boolean;
33
55
  }
56
+ export interface ParticipationDecision {
57
+ allow: boolean;
58
+ reasonCode: string;
59
+ reason: string;
60
+ }
34
61
  export interface ParticipationHistoryMessage {
62
+ id?: string;
35
63
  senderId: string;
36
64
  senderType: 'human' | 'ai_agent';
37
65
  metadata?: unknown;
@@ -47,13 +75,11 @@ export declare const PARTICIPATION_HISTORY_FETCH_LIMIT = 50;
47
75
  export declare function parseAgentBehaviorSettings(raw: unknown): AgentBehaviorSettingsRecord;
48
76
  export declare function normalizeStoredAgentBehaviorPolicy(raw: Record<string, unknown> | undefined): AgentBehaviorSettingsRecord | null;
49
77
  export declare function normalizeAgentBehaviorInstructions(value: string | null | undefined): string | null;
78
+ export declare function getDefaultParticipationPolicy(): ParticipationPolicy;
50
79
  export declare function resolveAgentBehaviorPolicy(params?: {
51
- agentDefault?: AgentBehaviorSettingsRecord | null;
52
- conversationOverride?: AgentBehaviorSettingsRecord | null;
80
+ agentDefault?: AgentBehaviorSettingsInput | null;
81
+ conversationOverride?: AgentBehaviorSettingsInput | null;
53
82
  }): ResolvedAgentBehaviorPolicyRecord;
54
83
  export declare function buildParticipationHistorySnapshot(messages: ParticipationHistoryMessage[], agentId?: string): ParticipationHistorySnapshot;
55
84
  export declare function appendParticipationHistoryMessage(snapshot: ParticipationHistorySnapshot, message: ParticipationHistoryMessage, limit?: number): ParticipationHistorySnapshot;
56
- export declare function evaluateParticipationPolicy(policy: ResolvedAgentBehaviorPolicyRecord, input: ParticipationDecisionInput): {
57
- allow: boolean;
58
- reason: string;
59
- };
85
+ export declare function evaluateParticipationPolicy(policy: ResolvedAgentBehaviorPolicyRecord | null | undefined, input: ParticipationDecisionInput): ParticipationDecision;
@@ -119,6 +119,9 @@ export function normalizeAgentBehaviorInstructions(value) {
119
119
  const trimmed = value.trim();
120
120
  return trimmed.length > 0 ? trimmed : null;
121
121
  }
122
+ export function getDefaultParticipationPolicy() {
123
+ return { ...DEFAULT_POLICY.participation };
124
+ }
122
125
  export function resolveAgentBehaviorPolicy(params) {
123
126
  const agentDefault = params?.agentDefault ?? null;
124
127
  const conversationOverride = params?.conversationOverride ?? null;
@@ -195,37 +198,59 @@ export function appendParticipationHistoryMessage(snapshot, message, limit = PAR
195
198
  ].slice(0, limit));
196
199
  }
197
200
  export function evaluateParticipationPolicy(policy, input) {
201
+ const resolved = policy ?? resolveAgentBehaviorPolicy();
202
+ const participation = resolved.participation;
198
203
  const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
199
204
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
200
205
  if (input.conversationType === 'group'
201
- && policy.participation.requireMentionForGroupReplies
206
+ && participation.requireMentionForGroupReplies
202
207
  && !input.mentionedAgent) {
203
208
  return {
204
209
  allow: false,
210
+ reasonCode: 'group_mention_required',
205
211
  reason: 'group replies require a direct mention',
206
212
  };
207
213
  }
208
214
  if (input.isOwner) {
209
- return { allow: true, reason: 'owner messages pass through outside mention-required group turns' };
215
+ return {
216
+ allow: true,
217
+ reasonCode: 'owner_sender',
218
+ reason: 'owner messages pass through outside mention-required group turns',
219
+ };
210
220
  }
211
221
  if (input.senderType !== 'ai_agent') {
212
- return { allow: true, reason: 'latest sender is human' };
222
+ return { allow: true, reasonCode: 'human_sender', reason: 'latest sender is human' };
213
223
  }
214
- if (!policy.participation.allowAgentToAgent) {
215
- return { allow: false, reason: 'agent-to-agent participation disabled by policy' };
224
+ if (!participation.allowAgentToAgent) {
225
+ return {
226
+ allow: false,
227
+ reasonCode: 'agent_to_agent_disabled',
228
+ reason: 'agent-to-agent participation is disabled by policy',
229
+ };
216
230
  }
217
- if (!policy.participation.allowLongRunningCollaboration
231
+ if (!participation.allowLongRunningCollaboration
218
232
  && (consecutiveAgentTurns > 1
219
233
  || !currentAgentStreakStartedByHuman)) {
220
234
  return {
221
235
  allow: false,
236
+ reasonCode: 'human_reset_required',
222
237
  reason: 'a fresh human steer is required before continuing agent collaboration',
223
238
  };
224
239
  }
225
- if (typeof policy.participation.maxConsecutiveAgentTurns === 'number'
226
- && policy.participation.maxConsecutiveAgentTurns >= 0
227
- && consecutiveAgentTurns > policy.participation.maxConsecutiveAgentTurns) {
228
- return { allow: false, reason: 'maximum consecutive agent turns reached' };
240
+ if (typeof participation.maxConsecutiveAgentTurns === 'number'
241
+ && participation.maxConsecutiveAgentTurns >= 0
242
+ && consecutiveAgentTurns > participation.maxConsecutiveAgentTurns) {
243
+ return {
244
+ allow: false,
245
+ reasonCode: 'agent_turn_limit_reached',
246
+ reason: 'maximum consecutive agent turns reached',
247
+ };
229
248
  }
230
- return { allow: true, reason: 'agent-to-agent participation allowed by policy' };
249
+ return {
250
+ allow: true,
251
+ reasonCode: input.conversationType === 'group' ? 'group_agent_allowed' : 'direct_agent_allowed',
252
+ reason: participation.requireMentionForGroupReplies
253
+ ? 'policy allows this directly mentioned group reply'
254
+ : 'agent-to-agent participation allowed by policy',
255
+ };
231
256
  }
@@ -0,0 +1,17 @@
1
+ /** The indexable fields a stored agent contributes to `searchPrefixes`. */
2
+ export interface AgentSearchIndexFields {
3
+ displayName?: string | null;
4
+ description?: string | null;
5
+ }
6
+ /** The agent shape a directory filter reads. */
7
+ export interface AgentSearchProfile {
8
+ displayName?: string | null;
9
+ agentConfig?: {
10
+ description?: string | null;
11
+ } | null;
12
+ }
13
+ /** Tolerant: non-string input normalises to ''. */
14
+ export declare function normalizeAgentDirectoryQuery(value: unknown): string;
15
+ export declare function getAgentDirectoryQueryTokens(query: string): string[];
16
+ export declare function buildAgentSearchPrefixes(input: AgentSearchIndexFields): string[];
17
+ export declare function matchesAgentSearchQuery(profile: AgentSearchProfile, query: string): boolean;
@@ -0,0 +1,58 @@
1
+ // The single agent-directory search implementation, shared by Cloud Functions
2
+ // (which writes `users/{agentId}.searchPrefixes` and filters discovery reads)
3
+ // and `@canonmsg/chat-domain` (which filters the same agents client-side in the
4
+ // app and on web). The server index and the client filter must agree, so both
5
+ // sides derive from these bodies rather than from hand-synced copies.
6
+ //
7
+ // Pure ECMAScript: no imports, no Node built-ins, no Firestore types — safe in
8
+ // CJS, in the browser bundle, and on Hermes.
9
+ const MAX_QUERY_LENGTH = 64;
10
+ const MAX_TOKEN_PREFIX_LENGTH = 32;
11
+ const MAX_SEARCH_PREFIXES = 256;
12
+ const TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
13
+ function tokenizeAgentSearchValue(value) {
14
+ const normalized = value
15
+ .normalize('NFKD')
16
+ .toLowerCase()
17
+ .trim();
18
+ return normalized.match(TOKEN_PATTERN) ?? [];
19
+ }
20
+ /** Tolerant: non-string input normalises to ''. */
21
+ export function normalizeAgentDirectoryQuery(value) {
22
+ if (typeof value !== 'string')
23
+ return '';
24
+ return tokenizeAgentSearchValue(value).join(' ').slice(0, MAX_QUERY_LENGTH);
25
+ }
26
+ export function getAgentDirectoryQueryTokens(query) {
27
+ return tokenizeAgentSearchValue(query).map((token) => token.slice(0, MAX_QUERY_LENGTH));
28
+ }
29
+ export function buildAgentSearchPrefixes(input) {
30
+ const seen = new Set();
31
+ for (const token of tokenizeAgentSearchValue([input.displayName, input.description].filter(Boolean).join(' '))) {
32
+ const normalizedToken = token.slice(0, MAX_QUERY_LENGTH);
33
+ const prefixLength = Math.min(normalizedToken.length, MAX_TOKEN_PREFIX_LENGTH);
34
+ for (let index = 1; index <= prefixLength; index += 1) {
35
+ seen.add(normalizedToken.slice(0, index));
36
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
37
+ return [...seen];
38
+ }
39
+ }
40
+ if (normalizedToken.length > MAX_TOKEN_PREFIX_LENGTH) {
41
+ seen.add(normalizedToken);
42
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
43
+ return [...seen];
44
+ }
45
+ }
46
+ }
47
+ return [...seen];
48
+ }
49
+ export function matchesAgentSearchQuery(profile, query) {
50
+ const tokens = getAgentDirectoryQueryTokens(query);
51
+ if (tokens.length === 0)
52
+ return true;
53
+ const prefixes = new Set(buildAgentSearchPrefixes({
54
+ displayName: profile.displayName,
55
+ description: profile.agentConfig?.description,
56
+ }));
57
+ return tokens.every((token) => prefixes.has(token));
58
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readCanonicalPolicy = readCanonicalPolicy;
4
+ /**
5
+ * Read a stored policy field, defaulting to the value Canon assigns on account
6
+ * creation. Anything unrecognized — missing, misspelled, or the retired
7
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
8
+ * 'open'.
9
+ */
10
+ function readCanonicalPolicy(value) {
11
+ if (value === 'open' || value === 'approval-required' || value === 'owner-only') {
12
+ return value;
13
+ }
14
+ return 'approval-required';
15
+ }
@@ -4,6 +4,7 @@ exports.PARTICIPATION_HISTORY_FETCH_LIMIT = void 0;
4
4
  exports.parseAgentBehaviorSettings = parseAgentBehaviorSettings;
5
5
  exports.normalizeStoredAgentBehaviorPolicy = normalizeStoredAgentBehaviorPolicy;
6
6
  exports.normalizeAgentBehaviorInstructions = normalizeAgentBehaviorInstructions;
7
+ exports.getDefaultParticipationPolicy = getDefaultParticipationPolicy;
7
8
  exports.resolveAgentBehaviorPolicy = resolveAgentBehaviorPolicy;
8
9
  exports.buildParticipationHistorySnapshot = buildParticipationHistorySnapshot;
9
10
  exports.appendParticipationHistoryMessage = appendParticipationHistoryMessage;
@@ -129,6 +130,9 @@ function normalizeAgentBehaviorInstructions(value) {
129
130
  const trimmed = value.trim();
130
131
  return trimmed.length > 0 ? trimmed : null;
131
132
  }
133
+ function getDefaultParticipationPolicy() {
134
+ return { ...DEFAULT_POLICY.participation };
135
+ }
132
136
  function resolveAgentBehaviorPolicy(params) {
133
137
  const agentDefault = params?.agentDefault ?? null;
134
138
  const conversationOverride = params?.conversationOverride ?? null;
@@ -205,37 +209,59 @@ function appendParticipationHistoryMessage(snapshot, message, limit = exports.PA
205
209
  ].slice(0, limit));
206
210
  }
207
211
  function evaluateParticipationPolicy(policy, input) {
212
+ const resolved = policy ?? resolveAgentBehaviorPolicy();
213
+ const participation = resolved.participation;
208
214
  const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
209
215
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
210
216
  if (input.conversationType === 'group'
211
- && policy.participation.requireMentionForGroupReplies
217
+ && participation.requireMentionForGroupReplies
212
218
  && !input.mentionedAgent) {
213
219
  return {
214
220
  allow: false,
221
+ reasonCode: 'group_mention_required',
215
222
  reason: 'group replies require a direct mention',
216
223
  };
217
224
  }
218
225
  if (input.isOwner) {
219
- return { allow: true, reason: 'owner messages pass through outside mention-required group turns' };
226
+ return {
227
+ allow: true,
228
+ reasonCode: 'owner_sender',
229
+ reason: 'owner messages pass through outside mention-required group turns',
230
+ };
220
231
  }
221
232
  if (input.senderType !== 'ai_agent') {
222
- return { allow: true, reason: 'latest sender is human' };
233
+ return { allow: true, reasonCode: 'human_sender', reason: 'latest sender is human' };
223
234
  }
224
- if (!policy.participation.allowAgentToAgent) {
225
- return { allow: false, reason: 'agent-to-agent participation disabled by policy' };
235
+ if (!participation.allowAgentToAgent) {
236
+ return {
237
+ allow: false,
238
+ reasonCode: 'agent_to_agent_disabled',
239
+ reason: 'agent-to-agent participation is disabled by policy',
240
+ };
226
241
  }
227
- if (!policy.participation.allowLongRunningCollaboration
242
+ if (!participation.allowLongRunningCollaboration
228
243
  && (consecutiveAgentTurns > 1
229
244
  || !currentAgentStreakStartedByHuman)) {
230
245
  return {
231
246
  allow: false,
247
+ reasonCode: 'human_reset_required',
232
248
  reason: 'a fresh human steer is required before continuing agent collaboration',
233
249
  };
234
250
  }
235
- if (typeof policy.participation.maxConsecutiveAgentTurns === 'number'
236
- && policy.participation.maxConsecutiveAgentTurns >= 0
237
- && consecutiveAgentTurns > policy.participation.maxConsecutiveAgentTurns) {
238
- return { allow: false, reason: 'maximum consecutive agent turns reached' };
251
+ if (typeof participation.maxConsecutiveAgentTurns === 'number'
252
+ && participation.maxConsecutiveAgentTurns >= 0
253
+ && consecutiveAgentTurns > participation.maxConsecutiveAgentTurns) {
254
+ return {
255
+ allow: false,
256
+ reasonCode: 'agent_turn_limit_reached',
257
+ reason: 'maximum consecutive agent turns reached',
258
+ };
239
259
  }
240
- return { allow: true, reason: 'agent-to-agent participation allowed by policy' };
260
+ return {
261
+ allow: true,
262
+ reasonCode: input.conversationType === 'group' ? 'group_agent_allowed' : 'direct_agent_allowed',
263
+ reason: participation.requireMentionForGroupReplies
264
+ ? 'policy allows this directly mentioned group reply'
265
+ : 'agent-to-agent participation allowed by policy',
266
+ };
241
267
  }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ // The single agent-directory search implementation, shared by Cloud Functions
3
+ // (which writes `users/{agentId}.searchPrefixes` and filters discovery reads)
4
+ // and `@canonmsg/chat-domain` (which filters the same agents client-side in the
5
+ // app and on web). The server index and the client filter must agree, so both
6
+ // sides derive from these bodies rather than from hand-synced copies.
7
+ //
8
+ // Pure ECMAScript: no imports, no Node built-ins, no Firestore types — safe in
9
+ // CJS, in the browser bundle, and on Hermes.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.normalizeAgentDirectoryQuery = normalizeAgentDirectoryQuery;
12
+ exports.getAgentDirectoryQueryTokens = getAgentDirectoryQueryTokens;
13
+ exports.buildAgentSearchPrefixes = buildAgentSearchPrefixes;
14
+ exports.matchesAgentSearchQuery = matchesAgentSearchQuery;
15
+ const MAX_QUERY_LENGTH = 64;
16
+ const MAX_TOKEN_PREFIX_LENGTH = 32;
17
+ const MAX_SEARCH_PREFIXES = 256;
18
+ const TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
19
+ function tokenizeAgentSearchValue(value) {
20
+ const normalized = value
21
+ .normalize('NFKD')
22
+ .toLowerCase()
23
+ .trim();
24
+ return normalized.match(TOKEN_PATTERN) ?? [];
25
+ }
26
+ /** Tolerant: non-string input normalises to ''. */
27
+ function normalizeAgentDirectoryQuery(value) {
28
+ if (typeof value !== 'string')
29
+ return '';
30
+ return tokenizeAgentSearchValue(value).join(' ').slice(0, MAX_QUERY_LENGTH);
31
+ }
32
+ function getAgentDirectoryQueryTokens(query) {
33
+ return tokenizeAgentSearchValue(query).map((token) => token.slice(0, MAX_QUERY_LENGTH));
34
+ }
35
+ function buildAgentSearchPrefixes(input) {
36
+ const seen = new Set();
37
+ for (const token of tokenizeAgentSearchValue([input.displayName, input.description].filter(Boolean).join(' '))) {
38
+ const normalizedToken = token.slice(0, MAX_QUERY_LENGTH);
39
+ const prefixLength = Math.min(normalizedToken.length, MAX_TOKEN_PREFIX_LENGTH);
40
+ for (let index = 1; index <= prefixLength; index += 1) {
41
+ seen.add(normalizedToken.slice(0, index));
42
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
43
+ return [...seen];
44
+ }
45
+ }
46
+ if (normalizedToken.length > MAX_TOKEN_PREFIX_LENGTH) {
47
+ seen.add(normalizedToken);
48
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
49
+ return [...seen];
50
+ }
51
+ }
52
+ }
53
+ return [...seen];
54
+ }
55
+ function matchesAgentSearchQuery(profile, query) {
56
+ const tokens = getAgentDirectoryQueryTokens(query);
57
+ if (tokens.length === 0)
58
+ return true;
59
+ const prefixes = new Set(buildAgentSearchPrefixes({
60
+ displayName: profile.displayName,
61
+ description: profile.agentConfig?.description,
62
+ }));
63
+ return tokens.every((token) => prefixes.has(token));
64
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ADMISSION_GRANTING_SOURCES = void 0;
4
+ exports.isAdmissionGrantingContactSource = isAdmissionGrantingContactSource;
5
+ /**
6
+ * Sources that represent a user-initiated relationship which grants admission
7
+ * past `inboundPolicy: 'approval-required'` and
8
+ * `groupJoinPolicy: 'approval-required'`.
9
+ *
10
+ * - `contact_request`: an explicit approval flow.
11
+ * - `direct_add`: the user added the other side directly.
12
+ * - `link` / `qr`: out-of-band invitations that imply consent.
13
+ * - `group`: established by being in a shared group.
14
+ * - `phone_book`: included intentionally — if a human has a registered Canon
15
+ * user in their device contacts, that is enough to start a human-human DM
16
+ * without a separate Canon request.
17
+ * - `open_inbound_message`: the unified source written when a delivered direct
18
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
19
+ * consented to open inbound; the resulting mutual contact is real.
20
+ *
21
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
22
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
23
+ * (`evaluatePolicy` / `buildPolicyContext`).
24
+ */
25
+ const ADMISSION_GRANTING_CONTACT_SOURCES = [
26
+ 'contact_request',
27
+ 'direct_add',
28
+ 'link',
29
+ 'qr',
30
+ 'group',
31
+ 'phone_book',
32
+ 'open_inbound_message',
33
+ ];
34
+ exports.ADMISSION_GRANTING_SOURCES = new Set(ADMISSION_GRANTING_CONTACT_SOURCES);
35
+ /**
36
+ * Returns true if a contact-doc source counts as an admission-granting
37
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
38
+ * both return `false` — every contact doc must have an explicit source after
39
+ * the cleanup.
40
+ */
41
+ function isAdmissionGrantingContactSource(source) {
42
+ if (source === null || source === undefined)
43
+ return false;
44
+ return exports.ADMISSION_GRANTING_SOURCES.has(source);
45
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ // Normalizers for values read straight off a Firestore document. Callers pass
3
+ // plain doc-shaped data, never a Firestore handle — this package holds no I/O.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.normalizeStoredString = normalizeStoredString;
6
+ exports.firestoreTimestampToISOString = firestoreTimestampToISOString;
7
+ /** Trimmed string, or null when absent/empty. */
8
+ function normalizeStoredString(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const trimmed = value.trim();
12
+ return trimmed.length > 0 ? trimmed : null;
13
+ }
14
+ /**
15
+ * Firestore Timestamp-like -> ISO 8601, or null. Accepts only objects exposing
16
+ * `toDate()`. A raw `Date` is deliberately NOT accepted: that matches both call
17
+ * sites this replaced. `message.ts` and `contactRequest.ts` keep their own
18
+ * Date-tolerant normalizers — a different contract, not this one.
19
+ */
20
+ function firestoreTimestampToISOString(value) {
21
+ if (!value || typeof value !== 'object')
22
+ return null;
23
+ const maybeTimestamp = value;
24
+ if (typeof maybeTimestamp.toDate !== 'function')
25
+ return null;
26
+ return maybeTimestamp.toDate().toISOString();
27
+ }
package/dist/cjs/index.js CHANGED
@@ -22,7 +22,13 @@ __exportStar(require("./runtimeCardFields.js"), exports);
22
22
  __exportStar(require("./runtimeCardStorage.js"), exports);
23
23
  __exportStar(require("./turnProtocol.js"), exports);
24
24
  __exportStar(require("./agentBehaviorPolicy.js"), exports);
25
+ __exportStar(require("./agentSearch.js"), exports);
25
26
  __exportStar(require("./contactRequest.js"), exports);
27
+ __exportStar(require("./contactSources.js"), exports);
26
28
  __exportStar(require("./verbContract.js"), exports);
27
29
  __exportStar(require("./verbSchemas.js"), exports);
28
30
  __exportStar(require("./verbWire.js"), exports);
31
+ __exportStar(require("./accessPolicy.js"), exports);
32
+ __exportStar(require("./firestoreValues.js"), exports);
33
+ __exportStar(require("./moderation.js"), exports);
34
+ __exportStar(require("./selfContext.js"), exports);
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readModerationStatus = readModerationStatus;
4
+ /**
5
+ * The single definition of "this account is ejected", read off an
6
+ * already-fetched `moderationUsers/{id}` document. A missing document reads
7
+ * 'active'. Fetching is the caller's job — this package holds no Firestore handle.
8
+ */
9
+ function readModerationStatus(data) {
10
+ const record = data;
11
+ return record?.status === 'ejected' ? 'ejected' : 'active';
12
+ }
@@ -1,7 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RUNTIME_CARD_LIMITS = void 0;
3
+ exports.RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES = exports.RUNTIME_CARD_LIMITS = exports.RUNTIME_CARD_FIELD_TYPES = void 0;
4
4
  exports.validateRuntimeCardFieldValues = validateRuntimeCardFieldValues;
5
+ /**
6
+ * Canonical `canon.card.v1` action-field type vocabulary. The runtime array is
7
+ * the single source of truth and the type is derived from it, so a value and a
8
+ * type cannot drift.
9
+ *
10
+ * ORDER IS LOAD-BEARING: scripts/generate-skill-bundles.mjs renders this list
11
+ * verbatim (via @canonmsg/rich-cards) into two committed SKILL.md files, and
12
+ * `npm run check:skill-bundles` fails on a byte change.
13
+ */
14
+ exports.RUNTIME_CARD_FIELD_TYPES = [
15
+ 'text',
16
+ 'textarea',
17
+ 'select',
18
+ 'multiSelect',
19
+ 'boolean',
20
+ 'date',
21
+ 'number',
22
+ 'currency',
23
+ 'searchSelect',
24
+ 'lineItems',
25
+ ];
5
26
  /**
6
27
  * Caps shared across runtime-card normalizers, response validation, and
7
28
  * authoring tools. Keep these backend-safe so Functions can import them.
@@ -12,6 +33,14 @@ exports.RUNTIME_CARD_LIMITS = {
12
33
  lineItemColumns: 8,
13
34
  searchSelectChoices: 100,
14
35
  };
36
+ /** Column cell types allowed inside a `lineItems` field. */
37
+ exports.RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES = [
38
+ 'text',
39
+ 'number',
40
+ 'currency',
41
+ 'date',
42
+ 'select',
43
+ ];
15
44
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
16
45
  function isRecord(value) {
17
46
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeStoredSelfContextId = normalizeStoredSelfContextId;
4
+ exports.serializeSelfContext = serializeSelfContext;
5
+ const verbContract_js_1 = require("./verbContract.js");
6
+ const firestoreValues_js_1 = require("./firestoreValues.js");
7
+ /** Stored self-context id, or null when absent, empty, or path-like. */
8
+ function normalizeStoredSelfContextId(value) {
9
+ const id = (0, firestoreValues_js_1.normalizeStoredString)(value);
10
+ if (!id || id.includes('/'))
11
+ return null;
12
+ return id;
13
+ }
14
+ /**
15
+ * Agent-facing projection of an `agentSelfContexts` document; null for anything
16
+ * that is not a usable cross-session context. Ownership and conversation
17
+ * scoping stay with the CALLER — both consumers check them against their own
18
+ * Firestore handle before calling this.
19
+ */
20
+ function serializeSelfContext(id, data) {
21
+ if (data.type !== verbContract_js_1.SELF_CONTEXT_TYPE)
22
+ return null;
23
+ const context = (0, firestoreValues_js_1.normalizeStoredString)(data.context);
24
+ if (!context)
25
+ return null;
26
+ return {
27
+ id,
28
+ type: verbContract_js_1.SELF_CONTEXT_TYPE,
29
+ context,
30
+ createdAt: (0, firestoreValues_js_1.firestoreTimestampToISOString)(data.createdAt),
31
+ updatedAt: (0, firestoreValues_js_1.firestoreTimestampToISOString)(data.updatedAt),
32
+ };
33
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Sources that represent a user-initiated relationship which grants admission
3
+ * past `inboundPolicy: 'approval-required'` and
4
+ * `groupJoinPolicy: 'approval-required'`.
5
+ *
6
+ * - `contact_request`: an explicit approval flow.
7
+ * - `direct_add`: the user added the other side directly.
8
+ * - `link` / `qr`: out-of-band invitations that imply consent.
9
+ * - `group`: established by being in a shared group.
10
+ * - `phone_book`: included intentionally — if a human has a registered Canon
11
+ * user in their device contacts, that is enough to start a human-human DM
12
+ * without a separate Canon request.
13
+ * - `open_inbound_message`: the unified source written when a delivered direct
14
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
15
+ * consented to open inbound; the resulting mutual contact is real.
16
+ *
17
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
18
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
19
+ * (`evaluatePolicy` / `buildPolicyContext`).
20
+ */
21
+ declare const ADMISSION_GRANTING_CONTACT_SOURCES: readonly ["contact_request", "direct_add", "link", "qr", "group", "phone_book", "open_inbound_message"];
22
+ export type AdmissionGrantingContactSource = (typeof ADMISSION_GRANTING_CONTACT_SOURCES)[number];
23
+ export declare const ADMISSION_GRANTING_SOURCES: ReadonlySet<AdmissionGrantingContactSource>;
24
+ /**
25
+ * Returns true if a contact-doc source counts as an admission-granting
26
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
27
+ * both return `false` — every contact doc must have an explicit source after
28
+ * the cleanup.
29
+ */
30
+ export declare function isAdmissionGrantingContactSource(source: string | null | undefined): boolean;
31
+ export {};
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sources that represent a user-initiated relationship which grants admission
3
+ * past `inboundPolicy: 'approval-required'` and
4
+ * `groupJoinPolicy: 'approval-required'`.
5
+ *
6
+ * - `contact_request`: an explicit approval flow.
7
+ * - `direct_add`: the user added the other side directly.
8
+ * - `link` / `qr`: out-of-band invitations that imply consent.
9
+ * - `group`: established by being in a shared group.
10
+ * - `phone_book`: included intentionally — if a human has a registered Canon
11
+ * user in their device contacts, that is enough to start a human-human DM
12
+ * without a separate Canon request.
13
+ * - `open_inbound_message`: the unified source written when a delivered direct
14
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
15
+ * consented to open inbound; the resulting mutual contact is real.
16
+ *
17
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
18
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
19
+ * (`evaluatePolicy` / `buildPolicyContext`).
20
+ */
21
+ const ADMISSION_GRANTING_CONTACT_SOURCES = [
22
+ 'contact_request',
23
+ 'direct_add',
24
+ 'link',
25
+ 'qr',
26
+ 'group',
27
+ 'phone_book',
28
+ 'open_inbound_message',
29
+ ];
30
+ export const ADMISSION_GRANTING_SOURCES = new Set(ADMISSION_GRANTING_CONTACT_SOURCES);
31
+ /**
32
+ * Returns true if a contact-doc source counts as an admission-granting
33
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
34
+ * both return `false` — every contact doc must have an explicit source after
35
+ * the cleanup.
36
+ */
37
+ export function isAdmissionGrantingContactSource(source) {
38
+ if (source === null || source === undefined)
39
+ return false;
40
+ return ADMISSION_GRANTING_SOURCES.has(source);
41
+ }
@@ -0,0 +1,9 @@
1
+ /** Trimmed string, or null when absent/empty. */
2
+ export declare function normalizeStoredString(value: unknown): string | null;
3
+ /**
4
+ * Firestore Timestamp-like -> ISO 8601, or null. Accepts only objects exposing
5
+ * `toDate()`. A raw `Date` is deliberately NOT accepted: that matches both call
6
+ * sites this replaced. `message.ts` and `contactRequest.ts` keep their own
7
+ * Date-tolerant normalizers — a different contract, not this one.
8
+ */
9
+ export declare function firestoreTimestampToISOString(value: unknown): string | null;
@@ -0,0 +1,23 @@
1
+ // Normalizers for values read straight off a Firestore document. Callers pass
2
+ // plain doc-shaped data, never a Firestore handle — this package holds no I/O.
3
+ /** Trimmed string, or null when absent/empty. */
4
+ export function normalizeStoredString(value) {
5
+ if (typeof value !== 'string')
6
+ return null;
7
+ const trimmed = value.trim();
8
+ return trimmed.length > 0 ? trimmed : null;
9
+ }
10
+ /**
11
+ * Firestore Timestamp-like -> ISO 8601, or null. Accepts only objects exposing
12
+ * `toDate()`. A raw `Date` is deliberately NOT accepted: that matches both call
13
+ * sites this replaced. `message.ts` and `contactRequest.ts` keep their own
14
+ * Date-tolerant normalizers — a different contract, not this one.
15
+ */
16
+ export function firestoreTimestampToISOString(value) {
17
+ if (!value || typeof value !== 'object')
18
+ return null;
19
+ const maybeTimestamp = value;
20
+ if (typeof maybeTimestamp.toDate !== 'function')
21
+ return null;
22
+ return maybeTimestamp.toDate().toISOString();
23
+ }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,13 @@ export * from './runtimeCardFields.js';
6
6
  export * from './runtimeCardStorage.js';
7
7
  export * from './turnProtocol.js';
8
8
  export * from './agentBehaviorPolicy.js';
9
+ export * from './agentSearch.js';
9
10
  export * from './contactRequest.js';
11
+ export * from './contactSources.js';
10
12
  export * from './verbContract.js';
11
13
  export * from './verbSchemas.js';
12
14
  export * from './verbWire.js';
15
+ export * from './accessPolicy.js';
16
+ export * from './firestoreValues.js';
17
+ export * from './moderation.js';
18
+ export * from './selfContext.js';
package/dist/index.js CHANGED
@@ -6,7 +6,13 @@ export * from './runtimeCardFields.js';
6
6
  export * from './runtimeCardStorage.js';
7
7
  export * from './turnProtocol.js';
8
8
  export * from './agentBehaviorPolicy.js';
9
+ export * from './agentSearch.js';
9
10
  export * from './contactRequest.js';
11
+ export * from './contactSources.js';
10
12
  export * from './verbContract.js';
11
13
  export * from './verbSchemas.js';
12
14
  export * from './verbWire.js';
15
+ export * from './accessPolicy.js';
16
+ export * from './firestoreValues.js';
17
+ export * from './moderation.js';
18
+ export * from './selfContext.js';
@@ -0,0 +1,7 @@
1
+ export type ModerationStatus = 'active' | 'ejected';
2
+ /**
3
+ * The single definition of "this account is ejected", read off an
4
+ * already-fetched `moderationUsers/{id}` document. A missing document reads
5
+ * 'active'. Fetching is the caller's job — this package holds no Firestore handle.
6
+ */
7
+ export declare function readModerationStatus(data: unknown): ModerationStatus;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The single definition of "this account is ejected", read off an
3
+ * already-fetched `moderationUsers/{id}` document. A missing document reads
4
+ * 'active'. Fetching is the caller's job — this package holds no Firestore handle.
5
+ */
6
+ export function readModerationStatus(data) {
7
+ const record = data;
8
+ return record?.status === 'ejected' ? 'ejected' : 'active';
9
+ }
@@ -1,4 +1,14 @@
1
- export type RuntimeCardFieldType = 'text' | 'textarea' | 'select' | 'multiSelect' | 'boolean' | 'date' | 'number' | 'currency' | 'searchSelect' | 'lineItems';
1
+ /**
2
+ * Canonical `canon.card.v1` action-field type vocabulary. The runtime array is
3
+ * the single source of truth and the type is derived from it, so a value and a
4
+ * type cannot drift.
5
+ *
6
+ * ORDER IS LOAD-BEARING: scripts/generate-skill-bundles.mjs renders this list
7
+ * verbatim (via @canonmsg/rich-cards) into two committed SKILL.md files, and
8
+ * `npm run check:skill-bundles` fails on a byte change.
9
+ */
10
+ export declare const RUNTIME_CARD_FIELD_TYPES: readonly ["text", "textarea", "select", "multiSelect", "boolean", "date", "number", "currency", "searchSelect", "lineItems"];
11
+ export type RuntimeCardFieldType = (typeof RUNTIME_CARD_FIELD_TYPES)[number];
2
12
  /**
3
13
  * Caps shared across runtime-card normalizers, response validation, and
4
14
  * authoring tools. Keep these backend-safe so Functions can import them.
@@ -15,7 +25,9 @@ export interface RuntimeCardFieldChoice {
15
25
  description?: string;
16
26
  preview?: string;
17
27
  }
18
- export type RuntimeCardLineItemColumnType = 'text' | 'number' | 'currency' | 'date' | 'select';
28
+ /** Column cell types allowed inside a `lineItems` field. */
29
+ export declare const RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES: readonly ["text", "number", "currency", "date", "select"];
30
+ export type RuntimeCardLineItemColumnType = (typeof RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES)[number];
19
31
  export interface RuntimeCardLineItemColumn {
20
32
  id: string;
21
33
  label: string;
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Canonical `canon.card.v1` action-field type vocabulary. The runtime array is
3
+ * the single source of truth and the type is derived from it, so a value and a
4
+ * type cannot drift.
5
+ *
6
+ * ORDER IS LOAD-BEARING: scripts/generate-skill-bundles.mjs renders this list
7
+ * verbatim (via @canonmsg/rich-cards) into two committed SKILL.md files, and
8
+ * `npm run check:skill-bundles` fails on a byte change.
9
+ */
10
+ export const RUNTIME_CARD_FIELD_TYPES = [
11
+ 'text',
12
+ 'textarea',
13
+ 'select',
14
+ 'multiSelect',
15
+ 'boolean',
16
+ 'date',
17
+ 'number',
18
+ 'currency',
19
+ 'searchSelect',
20
+ 'lineItems',
21
+ ];
1
22
  /**
2
23
  * Caps shared across runtime-card normalizers, response validation, and
3
24
  * authoring tools. Keep these backend-safe so Functions can import them.
@@ -8,6 +29,14 @@ export const RUNTIME_CARD_LIMITS = {
8
29
  lineItemColumns: 8,
9
30
  searchSelectChoices: 100,
10
31
  };
32
+ /** Column cell types allowed inside a `lineItems` field. */
33
+ export const RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES = [
34
+ 'text',
35
+ 'number',
36
+ 'currency',
37
+ 'date',
38
+ 'select',
39
+ ];
11
40
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
12
41
  function isRecord(value) {
13
42
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,9 @@
1
+ /** Stored self-context id, or null when absent, empty, or path-like. */
2
+ export declare function normalizeStoredSelfContextId(value: unknown): string | null;
3
+ /**
4
+ * Agent-facing projection of an `agentSelfContexts` document; null for anything
5
+ * that is not a usable cross-session context. Ownership and conversation
6
+ * scoping stay with the CALLER — both consumers check them against their own
7
+ * Firestore handle before calling this.
8
+ */
9
+ export declare function serializeSelfContext(id: string, data: Record<string, unknown>): Record<string, unknown> | null;
@@ -0,0 +1,29 @@
1
+ import { SELF_CONTEXT_TYPE } from './verbContract.js';
2
+ import { firestoreTimestampToISOString, normalizeStoredString } from './firestoreValues.js';
3
+ /** Stored self-context id, or null when absent, empty, or path-like. */
4
+ export function normalizeStoredSelfContextId(value) {
5
+ const id = normalizeStoredString(value);
6
+ if (!id || id.includes('/'))
7
+ return null;
8
+ return id;
9
+ }
10
+ /**
11
+ * Agent-facing projection of an `agentSelfContexts` document; null for anything
12
+ * that is not a usable cross-session context. Ownership and conversation
13
+ * scoping stay with the CALLER — both consumers check them against their own
14
+ * Firestore handle before calling this.
15
+ */
16
+ export function serializeSelfContext(id, data) {
17
+ if (data.type !== SELF_CONTEXT_TYPE)
18
+ return null;
19
+ const context = normalizeStoredString(data.context);
20
+ if (!context)
21
+ return null;
22
+ return {
23
+ id,
24
+ type: SELF_CONTEXT_TYPE,
25
+ context,
26
+ createdAt: firestoreTimestampToISOString(data.createdAt),
27
+ updatedAt: firestoreTimestampToISOString(data.updatedAt),
28
+ };
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",