@peers-app/peers-sdk 0.27.5 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/user-context.d.ts +7 -1
- package/dist/context/user-context.js +4 -9
- package/dist/contracts/contract-proxy.d.ts +5 -0
- package/dist/contracts/contract-proxy.js +2 -2
- package/dist/contracts/contract-resolvers.d.ts +2 -2
- package/dist/contracts/contract-resolvers.js +14 -15
- package/dist/contracts/system/network-contracts.d.ts +1 -1
- package/dist/contracts/tool-catalog-service.js +1 -1
- package/dist/data/assistants.d.ts +3 -3
- package/dist/data/authority-write-checks.d.ts +42 -0
- package/dist/data/authority-write-checks.js +324 -0
- package/dist/data/change-tracking.d.ts +4 -4
- package/dist/data/create-group.js +1 -0
- package/dist/data/devices.d.ts +27 -4
- package/dist/data/devices.js +31 -0
- package/dist/data/group-authority.d.ts +111 -0
- package/dist/data/group-authority.js +281 -0
- package/dist/data/group-members.d.ts +8 -2
- package/dist/data/group-members.js +41 -18
- package/dist/data/group-permissions.d.ts +15 -31
- package/dist/data/group-permissions.js +25 -95
- package/dist/data/group-resign.d.ts +38 -0
- package/dist/data/group-resign.js +71 -0
- package/dist/data/groups.d.ts +11 -0
- package/dist/data/groups.js +31 -13
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.js +3 -0
- package/dist/data/invites.d.ts +14 -0
- package/dist/data/orm/index.d.ts +1 -0
- package/dist/data/orm/index.js +1 -0
- package/dist/data/orm/sql.data-source.d.ts +2 -2
- package/dist/data/orm/sql.data-source.js +24 -22
- package/dist/data/orm/table-container.js +20 -11
- package/dist/data/orm/write-checks.d.ts +89 -0
- package/dist/data/orm/write-checks.js +115 -0
- package/dist/data/package-table-identity.d.ts +21 -0
- package/dist/data/package-table-identity.js +87 -0
- package/dist/data/package-version-resolver.d.ts +15 -0
- package/dist/data/package-version-resolver.js +70 -3
- package/dist/data/package-versions.d.ts +20 -14
- package/dist/data/package-versions.js +15 -9
- package/dist/data/packages.d.ts +6 -1
- package/dist/data/packages.js +15 -20
- package/dist/data/persistent-vars.js +2 -39
- package/dist/data/sync-recovery-queue.d.ts +4 -4
- package/dist/data/sync-recovery-queue.js +7 -1
- package/dist/data/tool-access-approvals.d.ts +3 -3
- package/dist/data/user-permissions.d.ts +33 -2
- package/dist/data/user-permissions.js +87 -10
- package/dist/data/users.d.ts +49 -12
- package/dist/data/users.js +21 -1
- package/dist/data/workflows.d.ts +1 -1
- package/dist/define/index.d.ts +1 -1
- package/dist/define/index.js +2 -1
- package/dist/define/manifest.js +9 -1
- package/dist/define/table.d.ts +15 -0
- package/dist/device/binary-peer-connection-v2.d.ts +1 -1
- package/dist/device/binary-peer-connection-v2.js +1 -1
- package/dist/device/connection.d.ts +1 -1
- package/dist/device/frame-cipher.d.ts +1 -1
- package/dist/device/frame-cipher.js +1 -1
- package/dist/device/get-trust-level-fn.js +3 -6
- package/dist/device-pairing/types.d.ts +26 -26
- package/dist/identity/user-row-change.d.ts +3 -1
- package/dist/identity/user-row-change.js +33 -18
- package/dist/invites/group-approval.js +23 -24
- package/dist/invites/invite-messages.d.ts +250 -28
- package/dist/invites/invite-token.d.ts +18 -0
- package/dist/invites/invite-token.js +2 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +27 -0
- package/dist/package-installer/package-installer.d.ts +1 -1
- package/dist/package-installer/package-installer.js +8 -3
- package/dist/package-installer/package-propagation.d.ts +2 -0
- package/dist/package-installer/package-propagation.js +20 -38
- package/dist/package-installer/package-remote-checker.js +15 -3
- package/dist/package-loader/isolated-artifact.d.ts +14 -0
- package/dist/package-loader/isolated-artifact.js +3 -3
- package/dist/package-loader/isolated-runtime.d.ts +3 -1
- package/dist/package-loader/isolated-table-gateway.js +4 -1
- package/dist/package-loader/package-loader.js +6 -0
- package/dist/peer-id.d.ts +19 -0
- package/dist/peer-id.js +41 -0
- package/dist/types/peer-device.d.ts +2 -0
- package/dist/types/workflow.d.ts +2 -2
- package/dist/utils.js +13 -32
- package/package.json +1 -1
- package/dist/data/package-permissions.d.ts +0 -14
- package/dist/data/package-permissions.js +0 -26
- package/dist/data/package-version-permissions.d.ts +0 -14
- package/dist/data/package-version-permissions.js +0 -30
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DataContext } from "../context/data-context";
|
|
2
2
|
import { type IUser } from "../data";
|
|
3
3
|
import type { DataSourceFactory, IDataChangedEvent, Table } from "../data/orm";
|
|
4
|
-
import {
|
|
4
|
+
import type { IPublicPrivateKeys } from "../keys";
|
|
5
5
|
import { type Observable } from "../observable";
|
|
6
6
|
/** Coordinates personal and group data contexts for one signed-in user. */
|
|
7
7
|
export declare class UserContext {
|
|
@@ -69,6 +69,12 @@ export declare class UserContext {
|
|
|
69
69
|
type: "peers-services" | "https";
|
|
70
70
|
url: string;
|
|
71
71
|
}[] | undefined;
|
|
72
|
+
anchorsSignature?: string | undefined;
|
|
73
|
+
keySuccession?: {
|
|
74
|
+
publicKey: string;
|
|
75
|
+
signature: string;
|
|
76
|
+
issuedAt: string;
|
|
77
|
+
}[] | undefined;
|
|
72
78
|
} | undefined>;
|
|
73
79
|
/**
|
|
74
80
|
* Sign and persist this user's identity, then copy it into every loaded
|
|
@@ -6,7 +6,6 @@ const data_context_1 = require("../context/data-context");
|
|
|
6
6
|
const data_1 = require("../data");
|
|
7
7
|
const user_permissions_1 = require("../data/user-permissions");
|
|
8
8
|
const events_1 = require("../events");
|
|
9
|
-
const keys_1 = require("../keys");
|
|
10
9
|
const observable_1 = require("../observable");
|
|
11
10
|
const utils_1 = require("../utils");
|
|
12
11
|
/** Coordinates personal and group data contexts for one signed-in user. */
|
|
@@ -278,16 +277,12 @@ class UserContext {
|
|
|
278
277
|
console.error("Group data context is missing group id. Something went wrong");
|
|
279
278
|
continue;
|
|
280
279
|
}
|
|
281
|
-
|
|
280
|
+
// Copy the exact row the authority read verified, rather than a row read before an
|
|
281
|
+
// asynchronous rebuild that may have verified a different, subsequently synced row.
|
|
282
|
+
const { getGroupAuthority } = await Promise.resolve().then(() => require("../data/group-authority"));
|
|
283
|
+
const { group: groupObject } = await getGroupAuthority(this, dataContext.groupId);
|
|
282
284
|
const myGroupObject = await (0, data_1.Groups)(this.userDataContext).get(dataContext.groupId);
|
|
283
285
|
if (groupObject && !(0, lodash_1.isEqual)(groupObject, myGroupObject)) {
|
|
284
|
-
try {
|
|
285
|
-
(0, keys_1.verifyObjectSignature)(groupObject);
|
|
286
|
-
}
|
|
287
|
-
catch (err) {
|
|
288
|
-
console.error(`Error verifying group object from group ${dataContext.groupId}:`, err);
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
286
|
await (0, data_1.Groups)(this.userDataContext).save(groupObject);
|
|
292
287
|
}
|
|
293
288
|
}
|
|
@@ -428,6 +428,11 @@ export interface IContractCallContext {
|
|
|
428
428
|
workflow?: IWorkflowRunContext;
|
|
429
429
|
/** User id of the caller (e.g. the remote device's user), if known. */
|
|
430
430
|
callerUserId?: string;
|
|
431
|
+
/**
|
|
432
|
+
* Signing key a remote caller authenticated with. Role lookups for `callerUserId` pass it, so a
|
|
433
|
+
* role counts only for the key it was granted to. Absent for same-device callers.
|
|
434
|
+
*/
|
|
435
|
+
callerPublicKey?: string;
|
|
431
436
|
/** User id of the local device executing the call. */
|
|
432
437
|
localUserId?: string;
|
|
433
438
|
/**
|
|
@@ -534,8 +534,8 @@ async function assertContractToolAccess(tool, call, context) {
|
|
|
534
534
|
}
|
|
535
535
|
const userLevel = userId
|
|
536
536
|
? context.dataContext
|
|
537
|
-
? await (0, group_permissions_1.getUserAccessLevel)(context.dataContext, userId)
|
|
538
|
-
: (0, access_level_1.accessLevelFromGroupRole)(await (0, group_permissions_1.getUserRole)(call.dataContextId || context.localUserId || userId, userId))
|
|
537
|
+
? await (0, group_permissions_1.getUserAccessLevel)(context.dataContext, userId, context.callerPublicKey)
|
|
538
|
+
: (0, access_level_1.accessLevelFromGroupRole)(await (0, group_permissions_1.getUserRole)(call.dataContextId || context.localUserId || userId, userId, undefined, context.callerPublicKey))
|
|
539
539
|
: access_level_1.AccessLevel.None;
|
|
540
540
|
return (0, action_dispatch_1.authorizeToolDispatch)({ ...context.authority, userId, userAccessLevel: userLevel }, required, mode, strict);
|
|
541
541
|
}
|
|
@@ -71,8 +71,8 @@ export declare function registerContractResolver(contractId: string, version: nu
|
|
|
71
71
|
*/
|
|
72
72
|
export declare function getContractResolver(contractId: string, version: number, providerPackageId?: string, dataContext?: DataContext): ContractResolver | undefined;
|
|
73
73
|
/**
|
|
74
|
-
* Verify that a connection- or message-authenticated remote identity matches
|
|
75
|
-
*
|
|
74
|
+
* Verify that a connection- or message-authenticated remote identity matches the user's
|
|
75
|
+
* identity on record, preferring the personal context over shared-group data.
|
|
76
76
|
*
|
|
77
77
|
* Unsigned discovery stubs, malformed signatures, and key mismatches fail closed. This is
|
|
78
78
|
* the identity half of remote authorization; trust and role decisions build on it.
|
|
@@ -160,8 +160,8 @@ function getContractResolver(contractId, version, providerPackageId, dataContext
|
|
|
160
160
|
return onlyScopedResolver;
|
|
161
161
|
}
|
|
162
162
|
/**
|
|
163
|
-
* Verify that a connection- or message-authenticated remote identity matches
|
|
164
|
-
*
|
|
163
|
+
* Verify that a connection- or message-authenticated remote identity matches the user's
|
|
164
|
+
* identity on record, preferring the personal context over shared-group data.
|
|
165
165
|
*
|
|
166
166
|
* Unsigned discovery stubs, malformed signatures, and key mismatches fail closed. This is
|
|
167
167
|
* the identity half of remote authorization; trust and role decisions build on it.
|
|
@@ -173,19 +173,15 @@ async function verifyRemoteContractIdentity(userContext, remoteIdentity) {
|
|
|
173
173
|
const identityContexts = [userContext.userDataContext, ...userContext.groupDataContexts.values()];
|
|
174
174
|
for (const dataContext of identityContexts) {
|
|
175
175
|
const user = await (0, users_1.Users)(dataContext).get(remoteIdentity.userId);
|
|
176
|
-
if (!user
|
|
177
|
-
user.userId !== remoteIdentity.userId ||
|
|
178
|
-
user.publicKey !== remoteIdentity.publicKey ||
|
|
179
|
-
user.publicBoxKey !== remoteIdentity.publicBoxKey) {
|
|
176
|
+
if (!user)
|
|
180
177
|
continue;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
178
|
+
// A member can supply a self-signed namesake in a group. It must never override an
|
|
179
|
+
// identity already pinned in personal contacts (especially the device owner's identity).
|
|
180
|
+
if (!(0, user_permissions_1.sameUserIdentity)(user, remoteIdentity))
|
|
181
|
+
return false;
|
|
182
|
+
if (!user.signature)
|
|
183
|
+
continue;
|
|
184
|
+
return (0, user_permissions_1.isSelfSignedUser)(user);
|
|
189
185
|
}
|
|
190
186
|
return false;
|
|
191
187
|
}
|
|
@@ -269,6 +265,7 @@ async function authorizeRemoteContractCall(call, userContext, remoteIdentity) {
|
|
|
269
265
|
dataContextId,
|
|
270
266
|
context: {
|
|
271
267
|
callerUserId: remoteIdentity.userId,
|
|
268
|
+
callerPublicKey: remoteIdentity.publicKey,
|
|
272
269
|
localUserId: userContext.userId,
|
|
273
270
|
callerDeviceId: remoteIdentity.deviceId,
|
|
274
271
|
// Never create a context the caller names: only this device's own groups.
|
|
@@ -286,7 +283,7 @@ async function authorizeRemoteContractCall(call, userContext, remoteIdentity) {
|
|
|
286
283
|
}
|
|
287
284
|
const dataContext = userContext.getDataContext(dataContextId);
|
|
288
285
|
await dataContext.packageLoader?.catalogReady;
|
|
289
|
-
const callerLevel = await (0, group_permissions_1.getUserAccessLevel)(dataContext, remoteIdentity.userId);
|
|
286
|
+
const callerLevel = await (0, group_permissions_1.getUserAccessLevel)(dataContext, remoteIdentity.userId, remoteIdentity.publicKey);
|
|
290
287
|
// Policy belongs to the executing provider, not the latest registration of this shape.
|
|
291
288
|
// Select after the async role lookup and retain the selection through endpoint resolution.
|
|
292
289
|
const registry = dataContext.packageLoader?.contractRegistry;
|
|
@@ -323,6 +320,7 @@ async function authorizeRemoteContractCall(call, userContext, remoteIdentity) {
|
|
|
323
320
|
providerPackageId: selected?.providerPackageId,
|
|
324
321
|
context: {
|
|
325
322
|
callerUserId: remoteIdentity.userId,
|
|
323
|
+
callerPublicKey: remoteIdentity.publicKey,
|
|
326
324
|
localUserId: userContext.userId,
|
|
327
325
|
callerDeviceId: remoteIdentity.deviceId,
|
|
328
326
|
dataContext,
|
|
@@ -490,6 +488,7 @@ function createUserContextContractProviderRouter(transport, opts) {
|
|
|
490
488
|
const { userContext, remoteIdentity } = opts;
|
|
491
489
|
const context = {
|
|
492
490
|
callerUserId: remoteIdentity.userId,
|
|
491
|
+
callerPublicKey: remoteIdentity.publicKey,
|
|
493
492
|
localUserId: userContext.userId,
|
|
494
493
|
callerDeviceId: remoteIdentity.deviceId,
|
|
495
494
|
dataContext: userContext.userDataContext,
|
|
@@ -199,7 +199,7 @@ export interface INetworkSyncRecoveryEntry {
|
|
|
199
199
|
attemptCount: number;
|
|
200
200
|
latestError: string;
|
|
201
201
|
status: "retrying" | "parked";
|
|
202
|
-
parkedReason?: "schema" | "unique-conflict" | "unresolvable";
|
|
202
|
+
parkedReason?: "schema" | "unique-conflict" | "unresolvable" | "authorization";
|
|
203
203
|
stuck: boolean;
|
|
204
204
|
/** True when this device has a row that "Keep this device's version" can snapshot. */
|
|
205
205
|
localRowExists: boolean;
|
|
@@ -172,7 +172,7 @@ async function projectToolCatalogAccess(dataContext, entry, callerContext) {
|
|
|
172
172
|
? runtime.getEffectiveToolLevel?.(entry.target, provider)
|
|
173
173
|
: (0, tool_access_1.recordAccessLevel)((await liveResolver(dataContext, entry.target, provider)?.({ dataContext }))?.tools?.[entry.target.toolName]?.tool);
|
|
174
174
|
if (accessLevel !== undefined) {
|
|
175
|
-
const callerAccessLevel = await (0, group_permissions_1.getUserAccessLevel)(dataContext, userId);
|
|
175
|
+
const callerAccessLevel = await (0, group_permissions_1.getUserAccessLevel)(dataContext, userId, callerContext.callerPublicKey);
|
|
176
176
|
entry.access = {
|
|
177
177
|
accessLevel,
|
|
178
178
|
callerAccessLevel,
|
|
@@ -22,8 +22,8 @@ export declare const assistantSchema: z.ZodObject<{
|
|
|
22
22
|
}, "strip", z.ZodTypeAny, {
|
|
23
23
|
name: string;
|
|
24
24
|
accessLevel: number;
|
|
25
|
-
assistantId: string;
|
|
26
25
|
createdAt: Date;
|
|
26
|
+
assistantId: string;
|
|
27
27
|
assistantRunnerToolId: string;
|
|
28
28
|
assistantRunnerConfig: {} & {
|
|
29
29
|
[k: string]: any;
|
|
@@ -56,8 +56,8 @@ export type IAssistant = z.infer<typeof assistantSchema>;
|
|
|
56
56
|
export declare function Assistants(dataContext?: DataContext): import("..").Table<{
|
|
57
57
|
name: string;
|
|
58
58
|
accessLevel: number;
|
|
59
|
-
assistantId: string;
|
|
60
59
|
createdAt: Date;
|
|
60
|
+
assistantId: string;
|
|
61
61
|
assistantRunnerToolId: string;
|
|
62
62
|
assistantRunnerConfig: {} & {
|
|
63
63
|
[k: string]: any;
|
|
@@ -87,8 +87,8 @@ export declare const groupPrimaryAssistantVar: import("./persistent-vars").Persi
|
|
|
87
87
|
export declare const getPrimaryAssistant: (dataContext?: DataContext) => Promise<{
|
|
88
88
|
name: string;
|
|
89
89
|
accessLevel: number;
|
|
90
|
-
assistantId: string;
|
|
91
90
|
createdAt: Date;
|
|
91
|
+
assistantId: string;
|
|
92
92
|
assistantRunnerToolId: string;
|
|
93
93
|
assistantRunnerConfig: {} & {
|
|
94
94
|
[k: string]: any;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IGroupMember } from "./group-members";
|
|
2
|
+
import type { IGroup } from "./groups";
|
|
3
|
+
import { type ITableWriteCheckContext } from "./orm/write-checks";
|
|
4
|
+
import type { IPackageVersion } from "./package-versions";
|
|
5
|
+
import type { IPackage } from "./packages";
|
|
6
|
+
/**
|
|
7
|
+
* A membership must be signed by a member who counts as at least Admin, at least the role
|
|
8
|
+
* granted, and at least the member's current role. Only the founder writes the founder's row.
|
|
9
|
+
* A membership is removed by a signed soft delete; a hard delete is allowed only for a
|
|
10
|
+
* duplicate row (the user has another membership row), as unique-conflict cleanup writes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkGroupMember(existing: IGroupMember | undefined, candidate: IGroupMember | undefined, context: ITableWriteCheckContext): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* The group row must be signed by the founder or an Admin who counts, and only the founder can
|
|
15
|
+
* name a different founder. Sync never deletes it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkGroup(existing: IGroup | undefined, candidate: IGroup | undefined, context: ITableWriteCheckContext): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* A Writer may add a package; changing an existing one beyond its name and description (the
|
|
20
|
+
* active version, follow policy, update URL, publisher key, repository, disabled) needs an
|
|
21
|
+
* Admin. Deletes stay open to any member, as the table allows locally.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkPackageWrite(existing: IPackage | undefined, candidate: IPackage | undefined, context: ITableWriteCheckContext): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Whether an Admin's device may act on a package row by itself: install releases from its
|
|
26
|
+
* `updateUrl`, copy releases signed by its publisher key in from other contexts, and select one
|
|
27
|
+
* by its follow policy. Each of those signs the result with the Admin's key, so the row must be
|
|
28
|
+
* one an Admin signed: a Writer may add a package row, or delete one and add it again, with any
|
|
29
|
+
* publisher key and update URL. A peers-core row instead qualifies only while it names the
|
|
30
|
+
* peers-core publisher key (or a revoked one, which is re-pinned to it before any release is
|
|
31
|
+
* checked), whoever signed it. Always true outside a group.
|
|
32
|
+
*
|
|
33
|
+
* @param pkg - The package row in `dataContext`.
|
|
34
|
+
* @param dataContext - The context the row belongs to.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isPackageRowAdminSigned(pkg: IPackage, dataContext: ITableWriteCheckContext["dataContext"]): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* A dev version needs a Writer's signature; any other tag needs an Admin's, so no member can
|
|
39
|
+
* choose the code other members' devices run. peers-core versions signed by its publisher are
|
|
40
|
+
* accepted from anyone, since every device installs the bundled release.
|
|
41
|
+
*/
|
|
42
|
+
export declare function checkPackageVersion(existing: IPackageVersion | undefined, candidate: IPackageVersion | undefined, context: ITableWriteCheckContext): Promise<void>;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkGroupMember = checkGroupMember;
|
|
4
|
+
exports.checkGroup = checkGroup;
|
|
5
|
+
exports.checkPackageWrite = checkPackageWrite;
|
|
6
|
+
exports.isPackageRowAdminSigned = isPackageRowAdminSigned;
|
|
7
|
+
exports.checkPackageVersion = checkPackageVersion;
|
|
8
|
+
const lodash_1 = require("lodash");
|
|
9
|
+
const package_author_signing_1 = require("../package-installer/package-author-signing");
|
|
10
|
+
const system_ids_1 = require("../system-ids");
|
|
11
|
+
const devices_1 = require("./devices");
|
|
12
|
+
const group_authority_1 = require("./group-authority");
|
|
13
|
+
const group_member_roles_1 = require("./group-member-roles");
|
|
14
|
+
const write_checks_1 = require("./orm/write-checks");
|
|
15
|
+
const package_table_identity_1 = require("./package-table-identity");
|
|
16
|
+
const persistent_vars_1 = require("./persistent-vars");
|
|
17
|
+
const user_permissions_1 = require("./user-permissions");
|
|
18
|
+
/**
|
|
19
|
+
* Write checks for the rows that decide authority in a group: memberships, the group row, user
|
|
20
|
+
* profiles (roles are traced through their keys), device ownership claims, packages and package
|
|
21
|
+
* versions. Sync writes rows without running the table classes' checks, so the host runs these
|
|
22
|
+
* on every synced change to a group context, and the table classes run the same checks on local
|
|
23
|
+
* saves. A refused change stays in history but its row isn't written. The personal context is
|
|
24
|
+
* left alone: only the user's own devices write it.
|
|
25
|
+
*
|
|
26
|
+
* Table names are literals here: the table modules import these checks, so importing their
|
|
27
|
+
* constants would make a cycle.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The counted member who signed `row` and their role under the group's verified authority;
|
|
31
|
+
* refuses the write when nobody who counts signed it. Call only for a group context.
|
|
32
|
+
*/
|
|
33
|
+
async function requireSigner(tableName, row, context) {
|
|
34
|
+
const authority = await (0, group_authority_1.getGroupAuthority)(context.dataContext.userContext, context.dataContext.dataContextId);
|
|
35
|
+
const signer = (0, group_authority_1.groupSigner)(authority, row);
|
|
36
|
+
if (!signer) {
|
|
37
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signature doesn't verify against a counted member's key");
|
|
38
|
+
}
|
|
39
|
+
return { authority, ...signer };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check the signed stamp: package approvals belong to this context, and a replacement must
|
|
43
|
+
* not have been signed before the row on record. The
|
|
44
|
+
* tables' `signAndSave` puts `signedAt` under the signature, so a member can't restore a revoked
|
|
45
|
+
* grant, an earlier group setting or an earlier release selection by serving a copy of an older,
|
|
46
|
+
* validly signed row as a new change. Remember the stored stamp in device-local personal
|
|
47
|
+
* storage before it can be deleted: an unsigned package delete must not erase revocations.
|
|
48
|
+
* A row signed before the field existed counts as oldest.
|
|
49
|
+
*/
|
|
50
|
+
async function checkSignedOrder(tableName, existing, candidate, context) {
|
|
51
|
+
if (candidate &&
|
|
52
|
+
write_checks_1.packageApprovalTables.has(tableName) &&
|
|
53
|
+
candidate.signedContextId !== context.dataContext.groupId) {
|
|
54
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signature is for another context or has no context");
|
|
55
|
+
}
|
|
56
|
+
if (candidate?.signedAt !== undefined && !Number.isSafeInteger(candidate.signedAt)) {
|
|
57
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "invalid signed timestamp");
|
|
58
|
+
}
|
|
59
|
+
const deletedFloor = write_checks_1.packageApprovalTables.has(tableName)
|
|
60
|
+
? await (0, write_checks_1.deletedSignedAt)(tableName, context, candidate ? undefined : existing)
|
|
61
|
+
: 0;
|
|
62
|
+
const floor = Math.max(deletedFloor, existing?.signedAt ?? 0);
|
|
63
|
+
if (candidate &&
|
|
64
|
+
((candidate.signedAt ?? 0) < floor ||
|
|
65
|
+
(deletedFloor > 0 && (candidate.signedAt ?? 0) <= deletedFloor))) {
|
|
66
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signed before the row on record");
|
|
67
|
+
}
|
|
68
|
+
if (candidate?.signedAt !== undefined &&
|
|
69
|
+
candidate.signedAt === existing?.signedAt &&
|
|
70
|
+
candidate.signature !== existing.signature) {
|
|
71
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "conflicting signatures at the same signed timestamp");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A membership must be signed by a member who counts as at least Admin, at least the role
|
|
76
|
+
* granted, and at least the member's current role. Only the founder writes the founder's row.
|
|
77
|
+
* A membership is removed by a signed soft delete; a hard delete is allowed only for a
|
|
78
|
+
* duplicate row (the user has another membership row), as unique-conflict cleanup writes.
|
|
79
|
+
*/
|
|
80
|
+
async function checkGroupMember(existing, candidate, context) {
|
|
81
|
+
const groupId = context.dataContext.groupId;
|
|
82
|
+
if (!groupId)
|
|
83
|
+
return;
|
|
84
|
+
const { GroupMembers } = await Promise.resolve().then(() => require("./group-members"));
|
|
85
|
+
if (!candidate) {
|
|
86
|
+
if (!existing)
|
|
87
|
+
return;
|
|
88
|
+
const rows = [
|
|
89
|
+
...(await GroupMembers(context.dataContext).list({ userId: existing.userId })),
|
|
90
|
+
...(context.retainedRows ?? []),
|
|
91
|
+
].filter((row) => !context.deletedRecordIds?.includes(row.groupMemberId));
|
|
92
|
+
if (rows.some((row) => row.groupId === existing.groupId &&
|
|
93
|
+
row.userId === existing.userId &&
|
|
94
|
+
row.groupMemberId !== existing.groupMemberId))
|
|
95
|
+
return;
|
|
96
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "memberships are removed by a signed delete");
|
|
97
|
+
}
|
|
98
|
+
if (candidate.groupId !== groupId || candidate.groupMemberId !== context.recordId) {
|
|
99
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "the row names another group");
|
|
100
|
+
}
|
|
101
|
+
if (existing &&
|
|
102
|
+
(existing.userId !== candidate.userId || existing.groupId !== candidate.groupId)) {
|
|
103
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "membership identity cannot change");
|
|
104
|
+
}
|
|
105
|
+
await checkSignedOrder("GroupMembers", existing, candidate, context);
|
|
106
|
+
const sameMember = await GroupMembers(context.dataContext).findOne({
|
|
107
|
+
groupId,
|
|
108
|
+
userId: candidate.userId,
|
|
109
|
+
});
|
|
110
|
+
if (sameMember && sameMember.groupMemberId !== candidate.groupMemberId) {
|
|
111
|
+
// Duplicate repair may change the primary key, but must retain the member's revocation.
|
|
112
|
+
await checkSignedOrder("GroupMembers", sameMember, candidate, context);
|
|
113
|
+
}
|
|
114
|
+
const { authority, signer, signerRole } = await requireSigner("GroupMembers", candidate, context);
|
|
115
|
+
if (candidate.userId === authority.founderUserId && signer !== authority.founderUserId) {
|
|
116
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "only the founder writes the founder's row");
|
|
117
|
+
}
|
|
118
|
+
const currentRole = authority.roles.get(candidate.userId) ?? group_member_roles_1.GroupMemberRole.None;
|
|
119
|
+
if (signerRole < group_member_roles_1.GroupMemberRole.Admin ||
|
|
120
|
+
signerRole < candidate.role ||
|
|
121
|
+
signerRole < currentRole) {
|
|
122
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", `${signer} (${group_member_roles_1.GroupMemberRole[signerRole] ?? signerRole}) can't set this membership`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The group row must be signed by the founder or an Admin who counts, and only the founder can
|
|
127
|
+
* name a different founder. Sync never deletes it.
|
|
128
|
+
*/
|
|
129
|
+
async function checkGroup(existing, candidate, context) {
|
|
130
|
+
const groupId = context.dataContext.groupId;
|
|
131
|
+
if (!groupId)
|
|
132
|
+
return;
|
|
133
|
+
if (!candidate) {
|
|
134
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "the group row isn't deleted by sync");
|
|
135
|
+
}
|
|
136
|
+
if (candidate.groupId !== groupId || context.recordId !== groupId) {
|
|
137
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "the row names another group");
|
|
138
|
+
}
|
|
139
|
+
await checkSignedOrder("Groups", existing, candidate, context);
|
|
140
|
+
// A counted signer implies a pinned founder: every key is traced back to one.
|
|
141
|
+
const { authority, signer, signerRole } = await requireSigner("Groups", candidate, context);
|
|
142
|
+
if (candidate.founderUserId !== authority.founderUserId && signer !== authority.founderUserId) {
|
|
143
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "only the founder can name a new founder");
|
|
144
|
+
}
|
|
145
|
+
if (signerRole < group_member_roles_1.GroupMemberRole.Admin) {
|
|
146
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", `${signer} isn't an Admin`);
|
|
147
|
+
}
|
|
148
|
+
if ((candidate.publicRole ?? group_member_roles_1.GroupMemberRole.None) > signerRole) {
|
|
149
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "public role exceeds the signer's role");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Fields of a Packages row a Writer may change; everything else needs an Admin. */
|
|
153
|
+
const writerPackageFields = new Set(["name", "description", "signature", "signedAt"]);
|
|
154
|
+
/**
|
|
155
|
+
* A Writer may add a package; changing an existing one beyond its name and description (the
|
|
156
|
+
* active version, follow policy, update URL, publisher key, repository, disabled) needs an
|
|
157
|
+
* Admin. Deletes stay open to any member, as the table allows locally.
|
|
158
|
+
*/
|
|
159
|
+
async function checkPackageWrite(existing, candidate, context) {
|
|
160
|
+
if (!context.dataContext.groupId)
|
|
161
|
+
return;
|
|
162
|
+
await checkSignedOrder("Packages", existing, candidate, context);
|
|
163
|
+
if (!candidate)
|
|
164
|
+
return;
|
|
165
|
+
const { signerRole } = await requireSigner("Packages", candidate, context);
|
|
166
|
+
const needsAdmin = existing
|
|
167
|
+
? [...new Set([...Object.keys(existing), ...Object.keys(candidate)])].some((field) => !writerPackageFields.has(field) &&
|
|
168
|
+
!(0, lodash_1.isEqual)(existing[field], candidate[field]))
|
|
169
|
+
: !!candidate.activePackageVersionId;
|
|
170
|
+
if (signerRole < (needsAdmin ? group_member_roles_1.GroupMemberRole.Admin : group_member_roles_1.GroupMemberRole.Writer)) {
|
|
171
|
+
throw (0, write_checks_1.writeNotAuthorized)("Packages", needsAdmin
|
|
172
|
+
? "only an Admin can change a package's settings or select a version"
|
|
173
|
+
: "only a Writer can add a package");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether an Admin's device may act on a package row by itself: install releases from its
|
|
178
|
+
* `updateUrl`, copy releases signed by its publisher key in from other contexts, and select one
|
|
179
|
+
* by its follow policy. Each of those signs the result with the Admin's key, so the row must be
|
|
180
|
+
* one an Admin signed: a Writer may add a package row, or delete one and add it again, with any
|
|
181
|
+
* publisher key and update URL. A peers-core row instead qualifies only while it names the
|
|
182
|
+
* peers-core publisher key (or a revoked one, which is re-pinned to it before any release is
|
|
183
|
+
* checked), whoever signed it. Always true outside a group.
|
|
184
|
+
*
|
|
185
|
+
* @param pkg - The package row in `dataContext`.
|
|
186
|
+
* @param dataContext - The context the row belongs to.
|
|
187
|
+
*/
|
|
188
|
+
async function isPackageRowAdminSigned(pkg, dataContext) {
|
|
189
|
+
if (!dataContext.groupId)
|
|
190
|
+
return true;
|
|
191
|
+
if (pkg.packageId === system_ids_1.peersCorePackageId) {
|
|
192
|
+
return (!!system_ids_1.peersCorePublishPublicKey &&
|
|
193
|
+
(pkg.publishPublicKey === system_ids_1.peersCorePublishPublicKey ||
|
|
194
|
+
(0, package_author_signing_1.isRevokedPublisherKey)(pkg.publishPublicKey)));
|
|
195
|
+
}
|
|
196
|
+
if (pkg.signedContextId !== dataContext.groupId)
|
|
197
|
+
return false;
|
|
198
|
+
const authority = await (0, group_authority_1.getGroupAuthority)(dataContext.userContext, dataContext.groupId);
|
|
199
|
+
return ((0, group_authority_1.groupSigner)(authority, pkg)?.signerRole ?? group_member_roles_1.GroupMemberRole.None) >= group_member_roles_1.GroupMemberRole.Admin;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A dev version needs a Writer's signature; any other tag needs an Admin's, so no member can
|
|
203
|
+
* choose the code other members' devices run. peers-core versions signed by its publisher are
|
|
204
|
+
* accepted from anyone, since every device installs the bundled release.
|
|
205
|
+
*/
|
|
206
|
+
async function checkPackageVersion(existing, candidate, context) {
|
|
207
|
+
if (!context.dataContext.groupId)
|
|
208
|
+
return;
|
|
209
|
+
if (!candidate)
|
|
210
|
+
return checkSignedOrder("PackageVersions", existing, candidate, context);
|
|
211
|
+
const isDev = candidate.versionTag === "dev" && (!existing || existing.versionTag === "dev");
|
|
212
|
+
if (!isDev &&
|
|
213
|
+
candidate.packageId === system_ids_1.peersCorePackageId &&
|
|
214
|
+
(0, package_author_signing_1.verifyPackageAuthorSignature)(candidate, system_ids_1.peersCorePublishPublicKey).valid) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await checkSignedOrder("PackageVersions", existing, candidate, context);
|
|
218
|
+
const { signerRole } = await requireSigner("PackageVersions", candidate, context);
|
|
219
|
+
if (signerRole < (isDev ? group_member_roles_1.GroupMemberRole.Writer : group_member_roles_1.GroupMemberRole.Admin)) {
|
|
220
|
+
throw (0, write_checks_1.writeNotAuthorized)("PackageVersions", isDev ? "only a Writer can add a dev version" : "only an Admin can release a version");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A user's profile row is theirs: once a self-signed row is stored, a replacement must be
|
|
225
|
+
* self-signed too, and it isn't deleted. Roles are traced through these rows' keys, so a member
|
|
226
|
+
* who could overwrite another member's row could stop that member's grants from counting. The
|
|
227
|
+
* same rule `UsersTable.save` applies locally; a key change is still judged by the anchors on
|
|
228
|
+
* record when the row syncs.
|
|
229
|
+
*/
|
|
230
|
+
async function checkUser(existing, candidate, context) {
|
|
231
|
+
if (!context.dataContext.groupId)
|
|
232
|
+
return;
|
|
233
|
+
if (candidate && candidate.userId !== context.recordId) {
|
|
234
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "the row names another user");
|
|
235
|
+
}
|
|
236
|
+
if (candidate?.signature && !(0, user_permissions_1.isSelfSignedUser)(candidate)) {
|
|
237
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "the profile isn't signed by its own key");
|
|
238
|
+
}
|
|
239
|
+
if (candidate) {
|
|
240
|
+
// The same identity/anchor rule applies to direct writes and recovery snapshots, which
|
|
241
|
+
// do not pass through the sync-specific Users hook. A personal pin also governs first insert.
|
|
242
|
+
const { resolveUserRowChange } = await Promise.resolve().then(() => require("../identity/user-row-change"));
|
|
243
|
+
const decision = await resolveUserRowChange({
|
|
244
|
+
personalContext: context.dataContext.userContext.userDataContext,
|
|
245
|
+
existing,
|
|
246
|
+
incoming: candidate,
|
|
247
|
+
});
|
|
248
|
+
if (decision.action !== "apply")
|
|
249
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", decision.reason);
|
|
250
|
+
candidate.anchors = decision.row.anchors;
|
|
251
|
+
candidate.anchorsSignature = decision.row.anchorsSignature;
|
|
252
|
+
if (!candidate.anchors)
|
|
253
|
+
delete candidate.anchors;
|
|
254
|
+
}
|
|
255
|
+
if (!existing?.signature)
|
|
256
|
+
return;
|
|
257
|
+
if (!candidate) {
|
|
258
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "a signed profile isn't deleted");
|
|
259
|
+
}
|
|
260
|
+
if (!candidate.signature) {
|
|
261
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "an unsigned profile can't replace a signed one");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A device its owner signed for (`ownerSignature`, checked against the owner's verified key)
|
|
266
|
+
* stays theirs: it isn't deleted, and a replacement keeps the owner and a valid signature. Mesh
|
|
267
|
+
* routing encrypts to a device's owner, so a member who could claim another member's device id
|
|
268
|
+
* would receive what was meant for it. Verified owners stay pinned across key changes; a new
|
|
269
|
+
* claim waits for its owner's verified profile. An unsigned discovery row may be replaced.
|
|
270
|
+
*/
|
|
271
|
+
async function checkDevice(existing, candidate, context) {
|
|
272
|
+
const { dataContext, recordId } = context;
|
|
273
|
+
if (!dataContext.groupId)
|
|
274
|
+
return;
|
|
275
|
+
if (candidate && candidate.deviceId !== recordId) {
|
|
276
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "the row names another device");
|
|
277
|
+
}
|
|
278
|
+
const { users } = await (0, group_authority_1.getGroupAuthority)(dataContext.userContext, dataContext.groupId);
|
|
279
|
+
const signed = (row) => !!row && (0, devices_1.isDeviceOwnerSigned)(row, users.get(row.userId)?.publicKey);
|
|
280
|
+
const vars = (0, persistent_vars_1.PersistentVars)(dataContext.userContext.userDataContext);
|
|
281
|
+
const name = `deviceOwner:${dataContext.groupId}:${recordId}`;
|
|
282
|
+
const remembered = await vars.findOne({ name, scope: "device" });
|
|
283
|
+
const ownerId = remembered?.value.value ??
|
|
284
|
+
(signed(existing) ? existing?.userId : signed(candidate) ? candidate?.userId : undefined);
|
|
285
|
+
// Remember a verified owner before writing: profile rotation/recovery must not unpin an
|
|
286
|
+
// offline device. This is local evidence, retained through history replay and compaction.
|
|
287
|
+
if (ownerId && !remembered) {
|
|
288
|
+
await vars.save({ persistentVarId: "", name, scope: "device", value: { value: ownerId } });
|
|
289
|
+
}
|
|
290
|
+
if (ownerId && !candidate) {
|
|
291
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "a device its owner signed for isn't deleted");
|
|
292
|
+
}
|
|
293
|
+
if (ownerId && (candidate?.userId !== ownerId || !candidate?.ownerSignature)) {
|
|
294
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "a device its owner signed for stays theirs");
|
|
295
|
+
}
|
|
296
|
+
// An unchanged established claim survives a key change; renewing it requires the new key.
|
|
297
|
+
// A new signed claim waits for its owner's verified profile, so it is pinned on first save.
|
|
298
|
+
if (candidate?.ownerSignature &&
|
|
299
|
+
!(ownerId && candidate.ownerSignature === existing?.ownerSignature) &&
|
|
300
|
+
!signed(candidate)) {
|
|
301
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "the ownership claim doesn't verify");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
(0, write_checks_1.registerTableWriteCheck)("Users", checkUser);
|
|
305
|
+
(0, write_checks_1.registerTableWriteCheck)("Devices", checkDevice);
|
|
306
|
+
(0, write_checks_1.registerTableWriteCheck)("GroupMembers", checkGroupMember);
|
|
307
|
+
(0, write_checks_1.registerTableWriteCheck)("Groups", checkGroup);
|
|
308
|
+
(0, write_checks_1.registerTableWriteCheck)("Packages", checkPackageWrite);
|
|
309
|
+
(0, write_checks_1.registerTableWriteCheck)("PackageVersions", checkPackageVersion);
|
|
310
|
+
(0, write_checks_1.registerTableWriteCheck)("PersistentVars", (_existing, candidate) => {
|
|
311
|
+
if (candidate && candidate.scope !== "group") {
|
|
312
|
+
throw (0, write_checks_1.writeNotAuthorized)("PersistentVars", "personal and device settings do not belong in a group");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
(0, write_checks_1.registerTableWriteCheck)("TableDefinitions", (_existing, candidate) => {
|
|
316
|
+
if (!candidate)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
(0, package_table_identity_1.validateTableDefinitionRecord)(candidate);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw (0, write_checks_1.writeNotAuthorized)("TableDefinitions", String(error));
|
|
323
|
+
}
|
|
324
|
+
});
|
|
@@ -26,10 +26,10 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
26
26
|
supersededAt: z.ZodOptional<z.ZodNumber>;
|
|
27
27
|
}, "strip", z.ZodTypeAny, {
|
|
28
28
|
path: string;
|
|
29
|
-
createdAt: number;
|
|
30
|
-
changeId: string;
|
|
31
29
|
tableName: string;
|
|
30
|
+
createdAt: number;
|
|
32
31
|
recordId: string;
|
|
32
|
+
changeId: string;
|
|
33
33
|
op: "set" | "delete" | "patch-text";
|
|
34
34
|
appliedAt: number;
|
|
35
35
|
value?: any;
|
|
@@ -37,10 +37,10 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
37
37
|
supersededAt?: number | undefined;
|
|
38
38
|
}, {
|
|
39
39
|
path: string;
|
|
40
|
-
createdAt: number;
|
|
41
|
-
changeId: string;
|
|
42
40
|
tableName: string;
|
|
41
|
+
createdAt: number;
|
|
43
42
|
recordId: string;
|
|
43
|
+
changeId: string;
|
|
44
44
|
op: "set" | "delete" | "patch-text";
|
|
45
45
|
appliedAt: number;
|
|
46
46
|
value?: any;
|