@govcore/federation 0.2.0 → 0.2.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.
@@ -1,83 +0,0 @@
1
- // @govcore/federation/connections — the org-to-org connection lifecycle.
2
- //
3
- // A connection is a bilateral, explicit link between two orgs. The requester's
4
- // org opens it (`pending`); only the *target* org may accept (`active`) or
5
- // reject (`rejected`). An active connection is what `getConnectedOrgIds` and the
6
- // federated read checks key off. Each transition is audited. App-layer auth
7
- // (who is an admin, whose org is acting) stays in the caller.
8
- import { and, eq, or } from 'drizzle-orm';
9
- import { orgConnections } from '@govcore/schema';
10
- import { writeAuditLog } from '@govcore/audit';
11
- /** All connections touching `orgId`, in either direction. */
12
- export async function getConnections(db, orgId) {
13
- return db
14
- .select()
15
- .from(orgConnections)
16
- .where(or(eq(orgConnections.fromOrgId, orgId), eq(orgConnections.toOrgId, orgId)));
17
- }
18
- /** An existing connection between two orgs in either direction, or null. */
19
- export async function findConnectionBetween(db, orgId, targetOrgId) {
20
- const [existing] = await db
21
- .select()
22
- .from(orgConnections)
23
- .where(or(and(eq(orgConnections.fromOrgId, orgId), eq(orgConnections.toOrgId, targetOrgId)), and(eq(orgConnections.fromOrgId, targetOrgId), eq(orgConnections.toOrgId, orgId))))
24
- .limit(1);
25
- return existing ?? null;
26
- }
27
- /**
28
- * Open a pending connection from `orgId` to `targetOrgId`. Throws if a
29
- * connection already exists in either direction. Audited.
30
- */
31
- export async function requestConnection(db, params) {
32
- if (params.orgId === params.targetOrgId) {
33
- throw new Error('Cannot connect an organization to itself');
34
- }
35
- const existing = await findConnectionBetween(db, params.orgId, params.targetOrgId);
36
- if (existing)
37
- throw new Error('Connection already exists or is pending');
38
- const [connection] = await db
39
- .insert(orgConnections)
40
- .values({
41
- fromOrgId: params.orgId,
42
- toOrgId: params.targetOrgId,
43
- status: 'pending',
44
- createdBy: params.actorUserId,
45
- })
46
- .returning();
47
- await writeAuditLog(db, {
48
- action: 'federation.connection.request',
49
- entityType: 'org_connection',
50
- entityId: connection.id,
51
- userId: params.actorUserId,
52
- organizationId: params.orgId,
53
- after: { targetOrgId: params.targetOrgId },
54
- });
55
- return connection;
56
- }
57
- /** Internal: a target-only status transition with an audit row. */
58
- async function transitionByTarget(db, params, status, action) {
59
- // Only the target org (toOrgId) may accept or reject.
60
- const [row] = await db
61
- .update(orgConnections)
62
- .set({ status, updatedAt: new Date() })
63
- .where(and(eq(orgConnections.id, params.connectionId), eq(orgConnections.toOrgId, params.orgId)))
64
- .returning();
65
- if (!row)
66
- throw new Error('Connection not found or not authorized');
67
- await writeAuditLog(db, {
68
- action,
69
- entityType: 'org_connection',
70
- entityId: params.connectionId,
71
- userId: params.actorUserId,
72
- organizationId: params.orgId,
73
- });
74
- return row;
75
- }
76
- /** Accept a pending connection. Only the target org may accept. Audited. */
77
- export async function acceptConnection(db, params) {
78
- return transitionByTarget(db, params, 'active', 'federation.connection.accept');
79
- }
80
- /** Reject a pending connection. Only the target org may reject. Audited. */
81
- export async function rejectConnection(db, params) {
82
- return transitionByTarget(db, params, 'rejected', 'federation.connection.reject');
83
- }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA"}
package/dist/links.d.ts DELETED
@@ -1,64 +0,0 @@
1
- import { crossOrgLinks, type GovcoreDb } from '@govcore/schema';
2
- export type CrossOrgLink = typeof crossOrgLinks.$inferSelect;
3
- /** What to do with a link request given any existing link for the same pair. */
4
- export type LinkRequestAction = 'block' | 'reactivate' | 'create';
5
- /**
6
- * Pure: a pending/active link blocks a new request; a previously rejected one is
7
- * reactivated; otherwise a fresh link is created.
8
- */
9
- export declare function resolveLinkRequest(existing: Pick<CrossOrgLink, 'status'> | null | undefined): LinkRequestAction;
10
- /** All links where `orgId` is either the source or target org. */
11
- export declare function getCrossOrgLinks(db: GovcoreDb, orgId: string): Promise<CrossOrgLink[]>;
12
- /** All links touching a given entity (on either side). */
13
- export declare function getLinksForEntity(db: GovcoreDb, entityType: string, entityId: string): Promise<CrossOrgLink[]>;
14
- export interface LinkEndpoints {
15
- sourceEntityType: string;
16
- sourceEntityId: string;
17
- targetEntityType: string;
18
- targetEntityId: string;
19
- }
20
- /** The existing link for an exact source→target entity pair, or null. */
21
- export declare function findCrossOrgLink(db: GovcoreDb, ends: LinkEndpoints): Promise<CrossOrgLink | null>;
22
- export interface RequestCrossOrgLinkParams extends LinkEndpoints {
23
- sourceOrgId: string;
24
- targetOrgId: string;
25
- linkType: string;
26
- actorUserId: string;
27
- }
28
- /**
29
- * Propose a cross-org link. Blocks if a pending/active link for the pair already
30
- * exists; reactivates a previously rejected one; otherwise creates a pending
31
- * link. Audited. Entity ownership/visibility must be validated by the caller.
32
- */
33
- export declare function requestCrossOrgLink(db: GovcoreDb, params: RequestCrossOrgLinkParams): Promise<CrossOrgLink>;
34
- export interface LinkDecisionParams {
35
- linkId: string;
36
- /** The acting org — must be the link's target org. */
37
- orgId: string;
38
- actorUserId: string;
39
- }
40
- /** Approve a pending link. Target org only. Audited. */
41
- export declare function approveCrossOrgLink(db: GovcoreDb, params: LinkDecisionParams): Promise<CrossOrgLink>;
42
- /** Reject a pending link with an optional reason. Target org only. Audited. */
43
- export declare function rejectCrossOrgLink(db: GovcoreDb, params: LinkDecisionParams & {
44
- reason?: string;
45
- }): Promise<CrossOrgLink>;
46
- /** Withdraw a link the *source* org proposed (any status). Audited. */
47
- export declare function withdrawCrossOrgLink(db: GovcoreDb, params: LinkDecisionParams): Promise<CrossOrgLink>;
48
- /** Revoke an active link from the *target* org. Audited. */
49
- export declare function revokeCrossOrgLink(db: GovcoreDb, params: LinkDecisionParams): Promise<CrossOrgLink>;
50
- /**
51
- * Flag every pending/active link touching an entity for review — used when the
52
- * entity's visibility drops so both orgs are alerted the link may no longer be
53
- * accessible. Caller must have verified the entity belongs to its org.
54
- */
55
- export declare function flagLinksForVisibilityDrop(db: GovcoreDb, entityType: string, entityId: string, reason: string): Promise<void>;
56
- /** Clear review flags on an entity's links — used when visibility is raised back. */
57
- export declare function clearLinksFlag(db: GovcoreDb, entityType: string, entityId: string): Promise<void>;
58
- /**
59
- * Delete every cross-org link between two orgs — called when their connection is
60
- * removed. Audits a snapshot of the removed links (when any) under the actor.
61
- * Returns the removed rows.
62
- */
63
- export declare function removeLinksForConnection(db: GovcoreDb, orgAId: string, orgBId: string, actorUserId: string, actorOrgId: string): Promise<CrossOrgLink[]>;
64
- //# sourceMappingURL=links.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../src/links.ts"],"names":[],"mappings":"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"}
package/dist/links.js DELETED
@@ -1,214 +0,0 @@
1
- // @govcore/federation/links — the cross-org content-link lifecycle.
2
- //
3
- // A cross-org link is an approved relationship between a content item in one org
4
- // and one in another. Entity ids carry no FK (they cross org boundaries) and the
5
- // link semantics are app-defined: `linkType`, `sourceEntityType`/`targetEntityType`
6
- // are plain strings here. The *source* org proposes a link; only the *target* org
7
- // approves or rejects it. Entity ownership and federated-visibility preconditions
8
- // are the caller's responsibility (they live over app-owned content) — this module
9
- // owns the link record and its transitions. Each transition is audited.
10
- import { and, eq, inArray, or } from 'drizzle-orm';
11
- import { crossOrgLinks } from '@govcore/schema';
12
- import { writeAuditLog } from '@govcore/audit';
13
- /**
14
- * Pure: a pending/active link blocks a new request; a previously rejected one is
15
- * reactivated; otherwise a fresh link is created.
16
- */
17
- export function resolveLinkRequest(existing) {
18
- if (!existing)
19
- return 'create';
20
- if (existing.status === 'pending' || existing.status === 'active')
21
- return 'block';
22
- return 'reactivate';
23
- }
24
- const touchesEntity = (entityType, entityId) => or(and(eq(crossOrgLinks.sourceEntityType, entityType), eq(crossOrgLinks.sourceEntityId, entityId)), and(eq(crossOrgLinks.targetEntityType, entityType), eq(crossOrgLinks.targetEntityId, entityId)));
25
- const betweenOrgs = (orgAId, orgBId) => or(and(eq(crossOrgLinks.sourceOrgId, orgAId), eq(crossOrgLinks.targetOrgId, orgBId)), and(eq(crossOrgLinks.sourceOrgId, orgBId), eq(crossOrgLinks.targetOrgId, orgAId)));
26
- /** All links where `orgId` is either the source or target org. */
27
- export async function getCrossOrgLinks(db, orgId) {
28
- return db
29
- .select()
30
- .from(crossOrgLinks)
31
- .where(or(eq(crossOrgLinks.sourceOrgId, orgId), eq(crossOrgLinks.targetOrgId, orgId)));
32
- }
33
- /** All links touching a given entity (on either side). */
34
- export async function getLinksForEntity(db, entityType, entityId) {
35
- return db.select().from(crossOrgLinks).where(touchesEntity(entityType, entityId));
36
- }
37
- /** The existing link for an exact source→target entity pair, or null. */
38
- export async function findCrossOrgLink(db, ends) {
39
- const [row] = await db
40
- .select()
41
- .from(crossOrgLinks)
42
- .where(and(eq(crossOrgLinks.sourceEntityType, ends.sourceEntityType), eq(crossOrgLinks.sourceEntityId, ends.sourceEntityId), eq(crossOrgLinks.targetEntityType, ends.targetEntityType), eq(crossOrgLinks.targetEntityId, ends.targetEntityId)))
43
- .limit(1);
44
- return row ?? null;
45
- }
46
- /**
47
- * Propose a cross-org link. Blocks if a pending/active link for the pair already
48
- * exists; reactivates a previously rejected one; otherwise creates a pending
49
- * link. Audited. Entity ownership/visibility must be validated by the caller.
50
- */
51
- export async function requestCrossOrgLink(db, params) {
52
- if (params.sourceOrgId === params.targetOrgId) {
53
- throw new Error('Use same-org relationships for links within one organization');
54
- }
55
- const existing = await findCrossOrgLink(db, params);
56
- const action = resolveLinkRequest(existing);
57
- if (action === 'block') {
58
- throw new Error('A cross-org link already exists or is awaiting approval');
59
- }
60
- let link;
61
- if (action === 'reactivate' && existing) {
62
- const [row] = await db
63
- .update(crossOrgLinks)
64
- .set({ linkType: params.linkType, status: 'pending', rejectionReason: null, updatedAt: new Date() })
65
- .where(eq(crossOrgLinks.id, existing.id))
66
- .returning();
67
- link = row;
68
- }
69
- else {
70
- const [row] = await db
71
- .insert(crossOrgLinks)
72
- .values({
73
- sourceOrgId: params.sourceOrgId,
74
- sourceEntityType: params.sourceEntityType,
75
- sourceEntityId: params.sourceEntityId,
76
- targetOrgId: params.targetOrgId,
77
- targetEntityType: params.targetEntityType,
78
- targetEntityId: params.targetEntityId,
79
- linkType: params.linkType,
80
- status: 'pending',
81
- createdBy: params.actorUserId,
82
- })
83
- .returning();
84
- link = row;
85
- }
86
- await writeAuditLog(db, {
87
- action: 'federation.cross_org_link.request',
88
- entityType: 'cross_org_link',
89
- entityId: link.id,
90
- userId: params.actorUserId,
91
- organizationId: params.sourceOrgId,
92
- after: {
93
- sourceEntityId: params.sourceEntityId,
94
- targetEntityId: params.targetEntityId,
95
- linkType: params.linkType,
96
- targetOrgId: params.targetOrgId,
97
- },
98
- });
99
- return link;
100
- }
101
- /** Internal: a target-org-only status transition with an audit row. */
102
- async function transitionByTarget(db, params, status, action) {
103
- const [link] = await db
104
- .select()
105
- .from(crossOrgLinks)
106
- .where(and(eq(crossOrgLinks.id, params.linkId), eq(crossOrgLinks.targetOrgId, params.orgId)))
107
- .limit(1);
108
- if (!link)
109
- throw new Error('Cross-org link not found or not authorized');
110
- if (link.status !== 'pending')
111
- throw new Error('Only pending links can be approved or rejected');
112
- const [row] = await db
113
- .update(crossOrgLinks)
114
- .set({
115
- status,
116
- rejectionReason: status === 'rejected' && params.reason?.trim() ? params.reason.trim() : null,
117
- updatedAt: new Date(),
118
- })
119
- .where(eq(crossOrgLinks.id, params.linkId))
120
- .returning();
121
- await writeAuditLog(db, {
122
- action,
123
- entityType: 'cross_org_link',
124
- entityId: params.linkId,
125
- userId: params.actorUserId,
126
- organizationId: params.orgId,
127
- after: { sourceEntityId: link.sourceEntityId, targetEntityId: link.targetEntityId },
128
- });
129
- return row;
130
- }
131
- /** Approve a pending link. Target org only. Audited. */
132
- export async function approveCrossOrgLink(db, params) {
133
- return transitionByTarget(db, params, 'active', 'federation.cross_org_link.approve');
134
- }
135
- /** Reject a pending link with an optional reason. Target org only. Audited. */
136
- export async function rejectCrossOrgLink(db, params) {
137
- return transitionByTarget(db, params, 'rejected', 'federation.cross_org_link.reject');
138
- }
139
- /** Internal: delete a link the acting org owns on the given side, audited. */
140
- async function deleteOwnedLink(db, params, ownerColumn, action) {
141
- const [link] = await db
142
- .select()
143
- .from(crossOrgLinks)
144
- .where(and(eq(crossOrgLinks.id, params.linkId), eq(ownerColumn, params.orgId)))
145
- .limit(1);
146
- if (!link)
147
- throw new Error('Cross-org link not found or not authorized');
148
- if (params.requireActive && link.status !== 'active') {
149
- throw new Error('Only active links can be revoked');
150
- }
151
- await db.delete(crossOrgLinks).where(eq(crossOrgLinks.id, params.linkId));
152
- await writeAuditLog(db, {
153
- action,
154
- entityType: 'cross_org_link',
155
- entityId: params.linkId,
156
- userId: params.actorUserId,
157
- organizationId: params.orgId,
158
- before: {
159
- sourceEntityId: link.sourceEntityId,
160
- targetEntityId: link.targetEntityId,
161
- status: link.status,
162
- },
163
- });
164
- return link;
165
- }
166
- /** Withdraw a link the *source* org proposed (any status). Audited. */
167
- export async function withdrawCrossOrgLink(db, params) {
168
- return deleteOwnedLink(db, params, crossOrgLinks.sourceOrgId, 'federation.cross_org_link.withdraw');
169
- }
170
- /** Revoke an active link from the *target* org. Audited. */
171
- export async function revokeCrossOrgLink(db, params) {
172
- return deleteOwnedLink(db, { ...params, requireActive: true }, crossOrgLinks.targetOrgId, 'federation.cross_org_link.revoke');
173
- }
174
- /**
175
- * Flag every pending/active link touching an entity for review — used when the
176
- * entity's visibility drops so both orgs are alerted the link may no longer be
177
- * accessible. Caller must have verified the entity belongs to its org.
178
- */
179
- export async function flagLinksForVisibilityDrop(db, entityType, entityId, reason) {
180
- await db
181
- .update(crossOrgLinks)
182
- .set({ flaggedForReview: true, flagReason: reason, updatedAt: new Date() })
183
- .where(and(touchesEntity(entityType, entityId), inArray(crossOrgLinks.status, ['pending', 'active'])));
184
- }
185
- /** Clear review flags on an entity's links — used when visibility is raised back. */
186
- export async function clearLinksFlag(db, entityType, entityId) {
187
- await db
188
- .update(crossOrgLinks)
189
- .set({ flaggedForReview: false, flagReason: null, updatedAt: new Date() })
190
- .where(and(touchesEntity(entityType, entityId), eq(crossOrgLinks.flaggedForReview, true)));
191
- }
192
- /**
193
- * Delete every cross-org link between two orgs — called when their connection is
194
- * removed. Audits a snapshot of the removed links (when any) under the actor.
195
- * Returns the removed rows.
196
- */
197
- export async function removeLinksForConnection(db, orgAId, orgBId, actorUserId, actorOrgId) {
198
- const affected = await db.select().from(crossOrgLinks).where(betweenOrgs(orgAId, orgBId));
199
- if (affected.length === 0)
200
- return [];
201
- await db.delete(crossOrgLinks).where(betweenOrgs(orgAId, orgBId));
202
- await writeAuditLog(db, {
203
- action: 'federation.cross_org_link.remove_for_connection',
204
- entityType: 'org_connection',
205
- userId: actorUserId,
206
- organizationId: actorOrgId,
207
- before: {
208
- orgAId,
209
- orgBId,
210
- removedLinkIds: affected.map((l) => l.id),
211
- },
212
- });
213
- return affected;
214
- }
@@ -1,45 +0,0 @@
1
- import { type SQL } from 'drizzle-orm';
2
- import type { AnyPgColumn } from 'drizzle-orm/pg-core';
3
- import { type GovcoreDb } from '@govcore/schema';
4
- /** A content row's visibility vocabulary (mirrors the `visibility` schema enum). */
5
- export type FederationVisibility = 'org' | 'connections' | 'instance';
6
- /**
7
- * List-view scope. `org` (default) restricts to the caller's own org;
8
- * `federated` is an explicit, user-requested broadening so list queries never
9
- * silently fan out across every visible org.
10
- */
11
- export type ListScope = 'org' | 'federated';
12
- /** Coerce a raw query-string value into a ListScope, defaulting to `org`. */
13
- export declare function parseListScope(value: string | string[] | undefined): ListScope;
14
- /**
15
- * Build the visibility WHERE condition for an org-scoped list query.
16
- *
17
- * - `org` scope → only rows owned by `orgId`.
18
- * - `federated` scope → owned rows, plus instance-wide rows, plus
19
- * connections/instance rows owned by a connected org.
20
- *
21
- * Pass `connectedOrgIds` empty under `org` scope (callers should skip the
22
- * connected-org lookup there). Compose with any status filter via `and(...)`.
23
- */
24
- export declare function listScopeFilter(cols: {
25
- organizationId: AnyPgColumn;
26
- visibility: AnyPgColumn;
27
- }, opts: {
28
- orgId: string;
29
- scope: ListScope;
30
- connectedOrgIds?: string[];
31
- }): SQL;
32
- /**
33
- * Throw if an entity's owning org doesn't match the caller's. Use before any
34
- * write to content fetched without an org filter.
35
- */
36
- export declare function assertOwnership(entityOrgId: string | null | undefined, callerOrgId: string): void;
37
- /** Org IDs with an *active* bilateral connection to `organizationId` (either direction). */
38
- export declare function getConnectedOrgIds(db: GovcoreDb, organizationId: string): Promise<string[]>;
39
- /**
40
- * Whether `callerOrgId` may read a single entity given its owning org and
41
- * visibility. Own-org always; `instance` always; `connections` only when an
42
- * active connection exists. A null org/visibility is unreadable.
43
- */
44
- export declare function canReadFederatedEntity(db: GovcoreDb, entityOrgId: string | null | undefined, visibility: FederationVisibility | null | undefined, callerOrgId: string): Promise<boolean>;
45
- //# sourceMappingURL=visibility.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"visibility.d.ts","sourceRoot":"","sources":["../src/visibility.ts"],"names":[],"mappings":"AASA,OAAO,EAAwB,KAAK,GAAG,EAAE,MAAM,aAAa,CAAA;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhE,oFAAoF;AACpF,MAAM,MAAM,oBAAoB,GAAG,KAAK,GAAG,aAAa,GAAG,UAAU,CAAA;AAErE;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,WAAW,CAAA;AAE3C,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,SAAS,CAE9E;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE;IAAE,cAAc,EAAE,WAAW,CAAC;IAAC,UAAU,EAAE,WAAW,CAAA;CAAE,EAC9D,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GACpE,GAAG,CAeL;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,WAAW,EAAE,MAAM,GAClB,IAAI,CAIN;AAED,4FAA4F;AAC5F,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,SAAS,EACb,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,MAAM,EAAE,CAAC,CAcnB;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,SAAS,EACb,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,UAAU,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,EACnD,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,OAAO,CAAC,CAQlB"}
@@ -1,68 +0,0 @@
1
- // @govcore/federation/visibility — federated read scoping.
2
- //
3
- // Content rows carry a `visibility` (org | connections | instance). A list query
4
- // is scoped to the caller's org by default; broadening to `federated` also pulls
5
- // in instance-wide rows and rows owned by a *connected* org whose visibility
6
- // permits it. These helpers build the query condition and answer single-row
7
- // read checks; the content tables themselves are app-owned (content is a later
8
- // milestone), so callers pass the columns.
9
- import { and, eq, inArray, or } from 'drizzle-orm';
10
- import { orgConnections } from '@govcore/schema';
11
- /** Coerce a raw query-string value into a ListScope, defaulting to `org`. */
12
- export function parseListScope(value) {
13
- return value === 'federated' ? 'federated' : 'org';
14
- }
15
- /**
16
- * Build the visibility WHERE condition for an org-scoped list query.
17
- *
18
- * - `org` scope → only rows owned by `orgId`.
19
- * - `federated` scope → owned rows, plus instance-wide rows, plus
20
- * connections/instance rows owned by a connected org.
21
- *
22
- * Pass `connectedOrgIds` empty under `org` scope (callers should skip the
23
- * connected-org lookup there). Compose with any status filter via `and(...)`.
24
- */
25
- export function listScopeFilter(cols, opts) {
26
- const base = eq(cols.organizationId, opts.orgId);
27
- if (opts.scope === 'org')
28
- return base;
29
- const instanceWide = eq(cols.visibility, 'instance');
30
- const connectedOrgIds = opts.connectedOrgIds ?? [];
31
- if (connectedOrgIds.length === 0)
32
- return or(base, instanceWide);
33
- return or(base, instanceWide, and(inArray(cols.organizationId, connectedOrgIds), inArray(cols.visibility, ['connections', 'instance'])));
34
- }
35
- /**
36
- * Throw if an entity's owning org doesn't match the caller's. Use before any
37
- * write to content fetched without an org filter.
38
- */
39
- export function assertOwnership(entityOrgId, callerOrgId) {
40
- if (!entityOrgId || entityOrgId !== callerOrgId) {
41
- throw new Error('Forbidden: content owned by another organization');
42
- }
43
- }
44
- /** Org IDs with an *active* bilateral connection to `organizationId` (either direction). */
45
- export async function getConnectedOrgIds(db, organizationId) {
46
- const rows = await db
47
- .select({ fromOrgId: orgConnections.fromOrgId, toOrgId: orgConnections.toOrgId })
48
- .from(orgConnections)
49
- .where(and(or(eq(orgConnections.fromOrgId, organizationId), eq(orgConnections.toOrgId, organizationId)), eq(orgConnections.status, 'active')));
50
- return rows.map((r) => (r.fromOrgId === organizationId ? r.toOrgId : r.fromOrgId));
51
- }
52
- /**
53
- * Whether `callerOrgId` may read a single entity given its owning org and
54
- * visibility. Own-org always; `instance` always; `connections` only when an
55
- * active connection exists. A null org/visibility is unreadable.
56
- */
57
- export async function canReadFederatedEntity(db, entityOrgId, visibility, callerOrgId) {
58
- if (!entityOrgId || !visibility)
59
- return false;
60
- if (entityOrgId === callerOrgId)
61
- return true;
62
- if (visibility === 'instance')
63
- return true;
64
- if (visibility !== 'connections')
65
- return false;
66
- const connectedOrgIds = await getConnectedOrgIds(db, callerOrgId);
67
- return connectedOrgIds.includes(entityOrgId);
68
- }
@@ -1,137 +0,0 @@
1
- // @govcore/federation/connections — the org-to-org connection lifecycle.
2
- //
3
- // A connection is a bilateral, explicit link between two orgs. The requester's
4
- // org opens it (`pending`); only the *target* org may accept (`active`) or
5
- // reject (`rejected`). An active connection is what `getConnectedOrgIds` and the
6
- // federated read checks key off. Each transition is audited. App-layer auth
7
- // (who is an admin, whose org is acting) stays in the caller.
8
-
9
- import { and, eq, or } from 'drizzle-orm'
10
- import { orgConnections, type GovcoreDb } from '@govcore/schema'
11
- import { writeAuditLog } from '@govcore/audit'
12
-
13
- export type OrgConnection = typeof orgConnections.$inferSelect
14
-
15
- /** All connections touching `orgId`, in either direction. */
16
- export async function getConnections(db: GovcoreDb, orgId: string): Promise<OrgConnection[]> {
17
- return db
18
- .select()
19
- .from(orgConnections)
20
- .where(or(eq(orgConnections.fromOrgId, orgId), eq(orgConnections.toOrgId, orgId)))
21
- }
22
-
23
- /** An existing connection between two orgs in either direction, or null. */
24
- export async function findConnectionBetween(
25
- db: GovcoreDb,
26
- orgId: string,
27
- targetOrgId: string,
28
- ): Promise<OrgConnection | null> {
29
- const [existing] = await db
30
- .select()
31
- .from(orgConnections)
32
- .where(
33
- or(
34
- and(eq(orgConnections.fromOrgId, orgId), eq(orgConnections.toOrgId, targetOrgId)),
35
- and(eq(orgConnections.fromOrgId, targetOrgId), eq(orgConnections.toOrgId, orgId)),
36
- ),
37
- )
38
- .limit(1)
39
- return existing ?? null
40
- }
41
-
42
- export interface RequestConnectionParams {
43
- /** The org opening the connection. */
44
- orgId: string
45
- /** The org being invited. */
46
- targetOrgId: string
47
- /** The acting user (recorded as `created_by` and on the audit row). */
48
- actorUserId: string
49
- }
50
-
51
- /**
52
- * Open a pending connection from `orgId` to `targetOrgId`. Throws if a
53
- * connection already exists in either direction. Audited.
54
- */
55
- export async function requestConnection(
56
- db: GovcoreDb,
57
- params: RequestConnectionParams,
58
- ): Promise<OrgConnection> {
59
- if (params.orgId === params.targetOrgId) {
60
- throw new Error('Cannot connect an organization to itself')
61
- }
62
- const existing = await findConnectionBetween(db, params.orgId, params.targetOrgId)
63
- if (existing) throw new Error('Connection already exists or is pending')
64
-
65
- const [connection] = await db
66
- .insert(orgConnections)
67
- .values({
68
- fromOrgId: params.orgId,
69
- toOrgId: params.targetOrgId,
70
- status: 'pending',
71
- createdBy: params.actorUserId,
72
- })
73
- .returning()
74
-
75
- await writeAuditLog(db, {
76
- action: 'federation.connection.request',
77
- entityType: 'org_connection',
78
- entityId: connection.id,
79
- userId: params.actorUserId,
80
- organizationId: params.orgId,
81
- after: { targetOrgId: params.targetOrgId },
82
- })
83
- return connection
84
- }
85
-
86
- /** Internal: a target-only status transition with an audit row. */
87
- async function transitionByTarget(
88
- db: GovcoreDb,
89
- params: { connectionId: string; orgId: string; actorUserId: string },
90
- status: 'active' | 'rejected',
91
- action: string,
92
- ): Promise<OrgConnection> {
93
- // Only the target org (toOrgId) may accept or reject.
94
- const [row] = await db
95
- .update(orgConnections)
96
- .set({ status, updatedAt: new Date() })
97
- .where(
98
- and(
99
- eq(orgConnections.id, params.connectionId),
100
- eq(orgConnections.toOrgId, params.orgId),
101
- ),
102
- )
103
- .returning()
104
- if (!row) throw new Error('Connection not found or not authorized')
105
-
106
- await writeAuditLog(db, {
107
- action,
108
- entityType: 'org_connection',
109
- entityId: params.connectionId,
110
- userId: params.actorUserId,
111
- organizationId: params.orgId,
112
- })
113
- return row
114
- }
115
-
116
- export interface ConnectionDecisionParams {
117
- connectionId: string
118
- /** The org acting — must be the connection's target org. */
119
- orgId: string
120
- actorUserId: string
121
- }
122
-
123
- /** Accept a pending connection. Only the target org may accept. Audited. */
124
- export async function acceptConnection(
125
- db: GovcoreDb,
126
- params: ConnectionDecisionParams,
127
- ): Promise<OrgConnection> {
128
- return transitionByTarget(db, params, 'active', 'federation.connection.accept')
129
- }
130
-
131
- /** Reject a pending connection. Only the target org may reject. Audited. */
132
- export async function rejectConnection(
133
- db: GovcoreDb,
134
- params: ConnectionDecisionParams,
135
- ): Promise<OrgConnection> {
136
- return transitionByTarget(db, params, 'rejected', 'federation.connection.reject')
137
- }
package/src/index.ts DELETED
@@ -1,15 +0,0 @@
1
- // @govcore/federation — cross-organization connections + federated visibility.
2
- //
3
- // connections: the bilateral org-to-org connection lifecycle (request / accept /
4
- // reject), audited; an active connection is what federated reads key off.
5
- // visibility: the org | connections | instance scoping helpers for list queries
6
- // and single-row read checks over app-owned content.
7
- //
8
- // links: the cross-org content-link lifecycle (request / approve / reject /
9
- // withdraw / revoke) plus review-flag and connection-cleanup helpers. Entity
10
- // types and link_type are app-defined strings; the app validates entity
11
- // ownership/visibility, this owns the link record.
12
-
13
- export * from './connections'
14
- export * from './visibility'
15
- export * from './links'