@korajs/spaces 0.0.0-canary-20261014043637
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/chunk-DZ4GBRUE.js +148 -0
- package/dist/chunk-DZ4GBRUE.js.map +1 -0
- package/dist/client.cjs +178 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +99 -0
- package/dist/client.d.ts +99 -0
- package/dist/client.js +142 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +209 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-DUU43t8s.d.cts +60 -0
- package/dist/protocol-DUU43t8s.d.ts +60 -0
- package/dist/react.cjs +188 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +60 -0
- package/dist/react.d.ts +60 -0
- package/dist/react.js +157 -0
- package/dist/react.js.map +1 -0
- package/dist/server.cjs +1260 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +234 -0
- package/dist/server.d.ts +234 -0
- package/dist/server.js +1225 -0
- package/dist/server.js.map +1 -0
- package/package.json +103 -0
- package/src/client/spaces-client.ts +266 -0
- package/src/client.ts +10 -0
- package/src/index.ts +27 -0
- package/src/protocol.ts +92 -0
- package/src/react/hooks.test.tsx +257 -0
- package/src/react/hooks.ts +255 -0
- package/src/react.ts +5 -0
- package/src/schema/spaces-collections.ts +225 -0
- package/src/server/decisions.ts +940 -0
- package/src/server/identity.ts +57 -0
- package/src/server/limiter.ts +67 -0
- package/src/server/spaces-routes.ts +766 -0
- package/src/server/tokens.ts +71 -0
- package/src/server/transfer-space.ts +68 -0
- package/src/server.ts +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dr. Obed Ehoneah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @korajs/spaces
|
|
2
|
+
|
|
3
|
+
Sharing for [Kora.js](https://github.com/ehoneahobed/kora) apps: email invitations, roles, members, leaving and transferring ownership, on top of access rules.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { spacesCollections } from '@korajs/spaces' // schema fragment
|
|
7
|
+
import { spacesRoutes, koraAuthIdentity } from '@korajs/spaces/server'
|
|
8
|
+
import { createSpaces } from '@korajs/spaces/client'
|
|
9
|
+
import { useMembers, useInvitations, useMyRole } from '@korajs/spaces/react'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Every action is one server transaction, so checks and writes commit together across concurrent requests and server instances. Members and pending invitations are synced data: readable offline, reactive.
|
|
13
|
+
|
|
14
|
+
Guide: [Sharing](https://korajs.dev/guide/sharing).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// src/schema/spaces-collections.ts
|
|
2
|
+
import { KoraError, memberOfKey, serverOnly, t } from "@korajs/core";
|
|
3
|
+
var SPACE_MEMBERS = "space_members";
|
|
4
|
+
var SPACE_INVITATIONS = "space_invitations";
|
|
5
|
+
var SPACE_INVITATION_SECRETS = "space_invitation_secrets";
|
|
6
|
+
var SpacesConfigError = class extends KoraError {
|
|
7
|
+
constructor(message, context) {
|
|
8
|
+
super(message, "SPACES_CONFIG_INVALID", context);
|
|
9
|
+
this.name = "SpacesConfigError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function spacesCollections(config) {
|
|
13
|
+
const roles = [...config.roles];
|
|
14
|
+
if (roles.length < 2) {
|
|
15
|
+
throw new SpacesConfigError(
|
|
16
|
+
"spacesCollections needs at least two roles (a member role and the owner role).",
|
|
17
|
+
{
|
|
18
|
+
roles
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const seen = /* @__PURE__ */ new Set();
|
|
23
|
+
for (const role of roles) {
|
|
24
|
+
if (typeof role !== "string" || role.length === 0) {
|
|
25
|
+
throw new SpacesConfigError("Every role must be a non-empty string.", { roles });
|
|
26
|
+
}
|
|
27
|
+
if (seen.has(role)) throw new SpacesConfigError(`Role "${role}" is listed twice.`, { roles });
|
|
28
|
+
if (role === "leave") {
|
|
29
|
+
throw new SpacesConfigError(
|
|
30
|
+
'"leave" cannot be a role name: transfers use it to mean "no role".',
|
|
31
|
+
{
|
|
32
|
+
roles
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
seen.add(role);
|
|
37
|
+
}
|
|
38
|
+
const ownerRank = roles.indexOf(config.ownerRole);
|
|
39
|
+
const manageRank = roles.indexOf(config.manageRole);
|
|
40
|
+
if (ownerRank === -1) {
|
|
41
|
+
throw new SpacesConfigError(`ownerRole "${config.ownerRole}" is not one of the roles.`, {
|
|
42
|
+
roles
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (manageRank === -1) {
|
|
46
|
+
throw new SpacesConfigError(`manageRole "${config.manageRole}" is not one of the roles.`, {
|
|
47
|
+
roles
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (ownerRank !== roles.length - 1) {
|
|
51
|
+
throw new SpacesConfigError(
|
|
52
|
+
`ownerRole "${config.ownerRole}" must be the highest role (last in roles). Add an owner role above "${roles[roles.length - 1]}".`,
|
|
53
|
+
{ roles, ownerRole: config.ownerRole }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (manageRank >= ownerRank) {
|
|
57
|
+
throw new SpacesConfigError(
|
|
58
|
+
"manageRole must be below ownerRole: managers invite and manage members, and only the owner owns the space.",
|
|
59
|
+
{ roles, ownerRole: config.ownerRole, manageRole: config.manageRole }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const groupEntries = Object.entries(config.groups);
|
|
63
|
+
if (groupEntries.length === 0) {
|
|
64
|
+
throw new SpacesConfigError("spacesCollections needs at least one group collection.");
|
|
65
|
+
}
|
|
66
|
+
const groups = {};
|
|
67
|
+
const accessGroups = {};
|
|
68
|
+
for (const [collection, group] of groupEntries) {
|
|
69
|
+
if (typeof group?.owner !== "string" || group.owner.length === 0) {
|
|
70
|
+
throw new SpacesConfigError(`Group collection "${collection}" needs its owner field.`, {
|
|
71
|
+
collection
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (collection === SPACE_MEMBERS || collection === SPACE_INVITATIONS || collection === SPACE_INVITATION_SECRETS) {
|
|
75
|
+
throw new SpacesConfigError(`"${collection}" is one of the kit's own collections.`, {
|
|
76
|
+
collection
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
groups[collection] = Object.freeze({ owner: group.owner });
|
|
80
|
+
accessGroups[collection] = { owner: group.owner, role: config.ownerRole };
|
|
81
|
+
}
|
|
82
|
+
const collections = {
|
|
83
|
+
space_members: {
|
|
84
|
+
fields: {
|
|
85
|
+
userId: t.string(),
|
|
86
|
+
group: t.string(),
|
|
87
|
+
role: t.string(),
|
|
88
|
+
invitedBy: t.string().optional(),
|
|
89
|
+
/** When the member accepted their invitation (server time). */
|
|
90
|
+
joinedAt: t.timestamp().optional(),
|
|
91
|
+
expiresAt: t.timestamp().optional()
|
|
92
|
+
},
|
|
93
|
+
indexes: ["group", "userId"],
|
|
94
|
+
// Members see who else is in their spaces. Only the server writes (no write rule).
|
|
95
|
+
access: { read: memberOfKey("group") }
|
|
96
|
+
},
|
|
97
|
+
space_invitations: {
|
|
98
|
+
fields: {
|
|
99
|
+
group: t.string(),
|
|
100
|
+
email: t.string(),
|
|
101
|
+
role: t.string(),
|
|
102
|
+
invitedBy: t.string(),
|
|
103
|
+
expiresAt: t.timestamp(),
|
|
104
|
+
createdAt: t.timestamp(),
|
|
105
|
+
tokenVersion: t.number()
|
|
106
|
+
},
|
|
107
|
+
indexes: ["group"],
|
|
108
|
+
// Invitee emails are visible to the space's managers only.
|
|
109
|
+
access: { read: memberOfKey("group", config.manageRole) }
|
|
110
|
+
},
|
|
111
|
+
space_invitation_secrets: {
|
|
112
|
+
fields: {
|
|
113
|
+
invitationId: t.string(),
|
|
114
|
+
group: t.string(),
|
|
115
|
+
tokenHash: t.string(),
|
|
116
|
+
tokenVersion: t.number(),
|
|
117
|
+
inviterId: t.string(),
|
|
118
|
+
authority: t.string(),
|
|
119
|
+
ownerVersion: t.string()
|
|
120
|
+
},
|
|
121
|
+
indexes: ["group", "inviterId"],
|
|
122
|
+
access: { read: serverOnly() }
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const frozenRoles = Object.freeze(roles);
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
roles: frozenRoles,
|
|
128
|
+
ownerRole: config.ownerRole,
|
|
129
|
+
manageRole: config.manageRole,
|
|
130
|
+
groups: Object.freeze(groups),
|
|
131
|
+
access: Object.freeze({
|
|
132
|
+
memberships: SPACE_MEMBERS,
|
|
133
|
+
roles: frozenRoles,
|
|
134
|
+
groups: accessGroups
|
|
135
|
+
}),
|
|
136
|
+
collections,
|
|
137
|
+
rank: (role) => frozenRoles.indexOf(role)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export {
|
|
142
|
+
SPACE_MEMBERS,
|
|
143
|
+
SPACE_INVITATIONS,
|
|
144
|
+
SPACE_INVITATION_SECRETS,
|
|
145
|
+
SpacesConfigError,
|
|
146
|
+
spacesCollections
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=chunk-DZ4GBRUE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema/spaces-collections.ts"],"sourcesContent":["import type { AccessConfigInput, CollectionInput } from '@korajs/core'\nimport { KoraError, memberOfKey, serverOnly, t } from '@korajs/core'\n\n/** The memberships collection the kit owns (`userId`, `group`, `role`, `invitedBy`). */\nexport const SPACE_MEMBERS = 'space_members'\n/** Pending invitations, readable by the group's managers. */\nexport const SPACE_INVITATIONS = 'space_invitations'\n/** Invitation token hashes and the inviter's authority. Never leaves the server. */\nexport const SPACE_INVITATION_SECRETS = 'space_invitation_secrets'\n\n/** A group collection shared through the kit: which field of its records holds the owner. */\nexport interface SpacesGroupConfig {\n\t/** The owner field, usually `t.string().stamp('userId')`. */\n\towner: string\n}\n\n/** Input of {@link spacesCollections}. */\nexport interface SpacesConfig<Role extends string = string, Group extends string = string> {\n\t/** Roles, lowest to highest. The owner role must be the highest. */\n\troles: readonly Role[]\n\t/** The role a group's owner holds, through the group record's owner field only. */\n\townerRole: Role\n\t/** The lowest role that may invite, change roles and remove members. Below `ownerRole`. */\n\tmanageRole: Role\n\t/** The collections whose records are shared spaces (a form, a document, a board). */\n\tgroups: Readonly<Record<Group, SpacesGroupConfig>>\n}\n\n/** The kit's three collections, ready to spread into `defineSchema({ collections })`. */\nexport interface SpacesCollectionsInput {\n\tspace_members: CollectionInput\n\tspace_invitations: CollectionInput\n\tspace_invitation_secrets: CollectionInput\n}\n\n/**\n * The result of {@link spacesCollections}: the schema parts, and the configuration the\n * server routes and React hooks read. Pass the same object to `spacesRoutes({ sharing })`.\n */\nexport interface Sharing<Role extends string = string, Group extends string = string> {\n\treadonly roles: readonly Role[]\n\treadonly ownerRole: Role\n\treadonly manageRole: Role\n\treadonly groups: Readonly<Record<Group, SpacesGroupConfig>>\n\t/** `defineSchema({ access })`. */\n\treadonly access: AccessConfigInput\n\t/** Spread into `defineSchema({ collections })`. */\n\treadonly collections: SpacesCollectionsInput\n\t/** The rank of `role` (0 = lowest), or -1 when it is not a role. */\n\trank(role: string): number\n}\n\n/** Thrown when `spacesCollections` is given a configuration it cannot share safely. */\nexport class SpacesConfigError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'SPACES_CONFIG_INVALID', context)\n\t\tthis.name = 'SpacesConfigError'\n\t}\n}\n\n/**\n * Declare sharing for one or more group collections: the access configuration (the\n * memberships collection, the roles, which collections are groups) and the kit's\n * collections (members, pending invitations, invitation secrets). Every membership and\n * invitation is written by the kit's server routes; clients read them as synced data.\n *\n * @param config - Roles (lowest to highest), the owner and manager roles, and the group collections\n * @returns The schema parts and the configuration to hand to `spacesRoutes`\n * @throws {SpacesConfigError} when the roles or groups cannot be shared safely\n *\n * @example\n * ```typescript\n * export const sharing = spacesCollections({\n * roles: ['viewer', 'editor', 'admin', 'owner'],\n * ownerRole: 'owner',\n * manageRole: 'admin',\n * groups: { forms: { owner: 'ownerId' } },\n * })\n *\n * export default defineSchema({\n * version: 1,\n * access: sharing.access,\n * collections: { ...sharing.collections, forms: { ... } },\n * })\n * ```\n */\nexport function spacesCollections<const Role extends string, const Group extends string>(\n\tconfig: SpacesConfig<Role, Group>,\n): Sharing<Role, Group> {\n\tconst roles = [...config.roles]\n\tif (roles.length < 2) {\n\t\tthrow new SpacesConfigError(\n\t\t\t'spacesCollections needs at least two roles (a member role and the owner role).',\n\t\t\t{\n\t\t\t\troles,\n\t\t\t},\n\t\t)\n\t}\n\tconst seen = new Set<string>()\n\tfor (const role of roles) {\n\t\tif (typeof role !== 'string' || role.length === 0) {\n\t\t\tthrow new SpacesConfigError('Every role must be a non-empty string.', { roles })\n\t\t}\n\t\tif (seen.has(role)) throw new SpacesConfigError(`Role \"${role}\" is listed twice.`, { roles })\n\t\tif (role === 'leave') {\n\t\t\t// `transfer({ previousOwner: 'leave' })` means \"keep no role\".\n\t\t\tthrow new SpacesConfigError(\n\t\t\t\t'\"leave\" cannot be a role name: transfers use it to mean \"no role\".',\n\t\t\t\t{\n\t\t\t\t\troles,\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t\tseen.add(role)\n\t}\n\tconst ownerRank = roles.indexOf(config.ownerRole)\n\tconst manageRank = roles.indexOf(config.manageRole)\n\tif (ownerRank === -1) {\n\t\tthrow new SpacesConfigError(`ownerRole \"${config.ownerRole}\" is not one of the roles.`, {\n\t\t\troles,\n\t\t})\n\t}\n\tif (manageRank === -1) {\n\t\tthrow new SpacesConfigError(`manageRole \"${config.manageRole}\" is not one of the roles.`, {\n\t\t\troles,\n\t\t})\n\t}\n\t// Nobody may outrank the owner: the owner can act on every member, and the owner role is\n\t// held only through the group record's owner field (it moves only by transfer).\n\tif (ownerRank !== roles.length - 1) {\n\t\tthrow new SpacesConfigError(\n\t\t\t`ownerRole \"${config.ownerRole}\" must be the highest role (last in roles). Add an owner role above \"${roles[roles.length - 1]}\".`,\n\t\t\t{ roles, ownerRole: config.ownerRole },\n\t\t)\n\t}\n\tif (manageRank >= ownerRank) {\n\t\tthrow new SpacesConfigError(\n\t\t\t'manageRole must be below ownerRole: managers invite and manage members, and only the owner owns the space.',\n\t\t\t{ roles, ownerRole: config.ownerRole, manageRole: config.manageRole },\n\t\t)\n\t}\n\tconst groupEntries = Object.entries(config.groups) as Array<[Group, SpacesGroupConfig]>\n\tif (groupEntries.length === 0) {\n\t\tthrow new SpacesConfigError('spacesCollections needs at least one group collection.')\n\t}\n\tconst groups = {} as Record<Group, SpacesGroupConfig>\n\tconst accessGroups: Record<string, { owner: string; role: string }> = {}\n\tfor (const [collection, group] of groupEntries) {\n\t\tif (typeof group?.owner !== 'string' || group.owner.length === 0) {\n\t\t\tthrow new SpacesConfigError(`Group collection \"${collection}\" needs its owner field.`, {\n\t\t\t\tcollection,\n\t\t\t})\n\t\t}\n\t\tif (\n\t\t\tcollection === SPACE_MEMBERS ||\n\t\t\tcollection === SPACE_INVITATIONS ||\n\t\t\tcollection === SPACE_INVITATION_SECRETS\n\t\t) {\n\t\t\tthrow new SpacesConfigError(`\"${collection}\" is one of the kit's own collections.`, {\n\t\t\t\tcollection,\n\t\t\t})\n\t\t}\n\t\tgroups[collection] = Object.freeze({ owner: group.owner })\n\t\taccessGroups[collection] = { owner: group.owner, role: config.ownerRole }\n\t}\n\n\tconst collections: SpacesCollectionsInput = {\n\t\tspace_members: {\n\t\t\tfields: {\n\t\t\t\tuserId: t.string(),\n\t\t\t\tgroup: t.string(),\n\t\t\t\trole: t.string(),\n\t\t\t\tinvitedBy: t.string().optional(),\n\t\t\t\t/** When the member accepted their invitation (server time). */\n\t\t\t\tjoinedAt: t.timestamp().optional(),\n\t\t\t\texpiresAt: t.timestamp().optional(),\n\t\t\t},\n\t\t\tindexes: ['group', 'userId'],\n\t\t\t// Members see who else is in their spaces. Only the server writes (no write rule).\n\t\t\taccess: { read: memberOfKey('group') },\n\t\t},\n\t\tspace_invitations: {\n\t\t\tfields: {\n\t\t\t\tgroup: t.string(),\n\t\t\t\temail: t.string(),\n\t\t\t\trole: t.string(),\n\t\t\t\tinvitedBy: t.string(),\n\t\t\t\texpiresAt: t.timestamp(),\n\t\t\t\tcreatedAt: t.timestamp(),\n\t\t\t\ttokenVersion: t.number(),\n\t\t\t},\n\t\t\tindexes: ['group'],\n\t\t\t// Invitee emails are visible to the space's managers only.\n\t\t\taccess: { read: memberOfKey('group', config.manageRole) },\n\t\t},\n\t\tspace_invitation_secrets: {\n\t\t\tfields: {\n\t\t\t\tinvitationId: t.string(),\n\t\t\t\tgroup: t.string(),\n\t\t\t\ttokenHash: t.string(),\n\t\t\t\ttokenVersion: t.number(),\n\t\t\t\tinviterId: t.string(),\n\t\t\t\tauthority: t.string(),\n\t\t\t\townerVersion: t.string(),\n\t\t\t},\n\t\t\tindexes: ['group', 'inviterId'],\n\t\t\taccess: { read: serverOnly() },\n\t\t},\n\t}\n\n\tconst frozenRoles = Object.freeze(roles) as readonly Role[]\n\treturn Object.freeze({\n\t\troles: frozenRoles,\n\t\townerRole: config.ownerRole,\n\t\tmanageRole: config.manageRole,\n\t\tgroups: Object.freeze(groups),\n\t\taccess: Object.freeze({\n\t\t\tmemberships: SPACE_MEMBERS,\n\t\t\troles: frozenRoles,\n\t\t\tgroups: accessGroups,\n\t\t}),\n\t\tcollections,\n\t\trank: (role: string): number => frozenRoles.indexOf(role as Role),\n\t})\n}\n"],"mappings":"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","names":[]}
|
package/dist/client.cjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/client.ts
|
|
21
|
+
var client_exports = {};
|
|
22
|
+
__export(client_exports, {
|
|
23
|
+
SpacesError: () => SpacesError,
|
|
24
|
+
SpacesOfflineError: () => SpacesOfflineError,
|
|
25
|
+
createSpaces: () => createSpaces
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(client_exports);
|
|
28
|
+
|
|
29
|
+
// src/client/spaces-client.ts
|
|
30
|
+
var import_core2 = require("@korajs/core");
|
|
31
|
+
var import_internal = require("@korajs/core/internal");
|
|
32
|
+
|
|
33
|
+
// src/schema/spaces-collections.ts
|
|
34
|
+
var import_core = require("@korajs/core");
|
|
35
|
+
var SPACE_MEMBERS = "space_members";
|
|
36
|
+
var SpacesConfigError = class extends import_core.KoraError {
|
|
37
|
+
constructor(message, context) {
|
|
38
|
+
super(message, "SPACES_CONFIG_INVALID", context);
|
|
39
|
+
this.name = "SpacesConfigError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// src/client/spaces-client.ts
|
|
44
|
+
var SpacesError = class extends import_core2.KoraError {
|
|
45
|
+
constructor(spacesCode, message, status) {
|
|
46
|
+
super(message, spacesCode, { status });
|
|
47
|
+
this.spacesCode = spacesCode;
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.name = "SpacesError";
|
|
50
|
+
}
|
|
51
|
+
spacesCode;
|
|
52
|
+
status;
|
|
53
|
+
};
|
|
54
|
+
var SpacesOfflineError = class extends SpacesError {
|
|
55
|
+
constructor(cause) {
|
|
56
|
+
super(
|
|
57
|
+
"OFFLINE",
|
|
58
|
+
"You are offline. Sharing changes need a connection; try again when you are back online.",
|
|
59
|
+
0
|
|
60
|
+
);
|
|
61
|
+
this.name = "SpacesOfflineError";
|
|
62
|
+
this.cause = cause;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var DEFAULT_ACCEPT_TIMEOUT_MS = 1e4;
|
|
66
|
+
function createSpaces(app, options = {}) {
|
|
67
|
+
const path = `/${(options.basePath ?? "/api/spaces").replace(/^\/+|\/+$/g, "")}`;
|
|
68
|
+
if (path === "/") {
|
|
69
|
+
throw new SpacesConfigError(
|
|
70
|
+
'createSpaces needs a basePath below the root, such as "/api/spaces".',
|
|
71
|
+
{
|
|
72
|
+
basePath: options.basePath
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const base = `${options.baseUrl?.replace(/\/+$/, "") ?? ""}${path}`;
|
|
77
|
+
const post = async (path2, body) => {
|
|
78
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await doFetch(`${base}${path2}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/json", ...await options.headers?.() },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
credentials: "same-origin",
|
|
86
|
+
referrerPolicy: "no-referrer"
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new SpacesOfflineError(error);
|
|
90
|
+
}
|
|
91
|
+
let parsed = null;
|
|
92
|
+
try {
|
|
93
|
+
parsed = await response.json();
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = null;
|
|
96
|
+
}
|
|
97
|
+
if (response.ok && (parsed === null || typeof parsed !== "object")) {
|
|
98
|
+
throw new SpacesOfflineError(new Error(`Unexpected response from ${path2}`));
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const error = parsed?.error;
|
|
102
|
+
if (!error?.code) {
|
|
103
|
+
throw new SpacesOfflineError(new Error(`HTTP ${response.status}`));
|
|
104
|
+
}
|
|
105
|
+
throw new SpacesError(
|
|
106
|
+
error.code,
|
|
107
|
+
error.message ?? error.code,
|
|
108
|
+
response.status
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return parsed;
|
|
112
|
+
};
|
|
113
|
+
const waitForMembership = (space, userId, joinedAt, timeoutMs) => new Promise((resolve) => {
|
|
114
|
+
const collection = app.collections[SPACE_MEMBERS];
|
|
115
|
+
if (!collection) {
|
|
116
|
+
resolve(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let settled = false;
|
|
120
|
+
let unsubscribe = null;
|
|
121
|
+
const finish = (arrived) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
queueMicrotask(() => unsubscribe?.());
|
|
126
|
+
resolve(arrived);
|
|
127
|
+
};
|
|
128
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
129
|
+
unsubscribe = collection.where({ group: (0, import_internal.groupKey)(space[0], space[1]), userId }).subscribe((rows) => {
|
|
130
|
+
if (rows.some((row) => row.joinedAt === joinedAt)) finish(true);
|
|
131
|
+
});
|
|
132
|
+
if (settled) unsubscribe();
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
invite: (space, input) => post("/invitations/create", { space, email: input.email, role: input.role }),
|
|
136
|
+
async revokeInvitation(invitationId) {
|
|
137
|
+
await post("/invitations/revoke", { invitationId });
|
|
138
|
+
},
|
|
139
|
+
describe: (token) => post("/invitations/describe", { token }),
|
|
140
|
+
async accept(token, acceptOptions) {
|
|
141
|
+
const accepted = await post("/invitations/accept", { token });
|
|
142
|
+
const arrived = await waitForMembership(
|
|
143
|
+
accepted.space,
|
|
144
|
+
accepted.userId,
|
|
145
|
+
accepted.joinedAt,
|
|
146
|
+
acceptOptions?.timeoutMs ?? DEFAULT_ACCEPT_TIMEOUT_MS
|
|
147
|
+
);
|
|
148
|
+
return { ...accepted, pending: !arrived };
|
|
149
|
+
},
|
|
150
|
+
async decline(token) {
|
|
151
|
+
await post("/invitations/decline", { token });
|
|
152
|
+
},
|
|
153
|
+
changeRole: (space, userId, role, changeOptions) => post("/members/role", {
|
|
154
|
+
space,
|
|
155
|
+
userId,
|
|
156
|
+
role,
|
|
157
|
+
...changeOptions?.from !== void 0 ? { from: changeOptions.from } : {}
|
|
158
|
+
}),
|
|
159
|
+
remove: (space, userId, removeOptions) => post("/members/remove", {
|
|
160
|
+
space,
|
|
161
|
+
userId,
|
|
162
|
+
...removeOptions?.from !== void 0 ? { from: removeOptions.from } : {}
|
|
163
|
+
}),
|
|
164
|
+
leave: (space) => post("/leave", { space }),
|
|
165
|
+
transfer: (space, toUserId, transferOptions) => post("/transfer", {
|
|
166
|
+
space,
|
|
167
|
+
toUserId,
|
|
168
|
+
...transferOptions?.previousOwner !== void 0 ? { previousOwner: transferOptions.previousOwner } : {}
|
|
169
|
+
})
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
173
|
+
0 && (module.exports = {
|
|
174
|
+
SpacesError,
|
|
175
|
+
SpacesOfflineError,
|
|
176
|
+
createSpaces
|
|
177
|
+
});
|
|
178
|
+
//# sourceMappingURL=client.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts","../src/client/spaces-client.ts","../src/schema/spaces-collections.ts"],"sourcesContent":["// @korajs/spaces/client: the sharing actions, one request each.\n// Every export here is a public API commitment. Be explicit.\n\nexport { createSpaces, SpacesError, SpacesOfflineError } from './client/spaces-client'\nexport type {\n\tAcceptResult,\n\tSpacesAppLike,\n\tSpacesClient,\n\tSpacesClientOptions,\n} from './client/spaces-client'\n","import { KoraError } from '@korajs/core'\nimport { groupKey } from '@korajs/core/internal'\nimport type {\n\tAcceptInvitationResponse,\n\tCreateInvitationResponse,\n\tInvitationDescription,\n\tMemberChangeResponse,\n\tSpaceRef,\n\tSpacesErrorCode,\n} from '../protocol'\nimport { SPACE_MEMBERS, SpacesConfigError } from '../schema/spaces-collections'\n\n/** The parts of a Kora app the client reads: the local members collection. */\nexport interface SpacesAppLike {\n\tcollections: Readonly<\n\t\tRecord<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\twhere(conditions: Record<string, unknown>): {\n\t\t\t\t\tsubscribe(callback: (rows: readonly Record<string, unknown>[]) => void): () => void\n\t\t\t\t}\n\t\t\t}\n\t\t>\n\t>\n}\n\n/** Options of {@link createSpaces}. */\nexport interface SpacesClientOptions {\n\t/** Where `spacesRoutes` is mounted (default `/api/spaces`). */\n\tbasePath?: string\n\t/** The server origin, when the routes are not on the page's own origin (`https://api.example.com`). */\n\tbaseUrl?: string\n\t/**\n\t * Headers for every request, usually the signed-in user's bearer token:\n\t * `headers: async () => ({ authorization: \\`Bearer ${await getAccessToken()}\\` })`.\n\t */\n\theaders?: () => Promise<Record<string, string>> | Record<string, string>\n\t/** A fetch implementation (default: the global `fetch`). */\n\tfetch?: typeof fetch\n}\n\n/** A refusal from the spaces routes. `code` is stable; `message` is for people. */\nexport class SpacesError extends KoraError {\n\tconstructor(\n\t\treadonly spacesCode: SpacesErrorCode | 'OFFLINE',\n\t\tmessage: string,\n\t\treadonly status: number,\n\t) {\n\t\tsuper(message, spacesCode, { status })\n\t\tthis.name = 'SpacesError'\n\t}\n}\n\n/**\n * The server could not be reached. Sharing changes are server decisions, so nothing is\n * queued: try again when online.\n */\nexport class SpacesOfflineError extends SpacesError {\n\tconstructor(cause: unknown) {\n\t\tsuper(\n\t\t\t'OFFLINE',\n\t\t\t'You are offline. Sharing changes need a connection; try again when you are back online.',\n\t\t\t0,\n\t\t)\n\t\tthis.name = 'SpacesOfflineError'\n\t\tthis.cause = cause\n\t}\n}\n\n/** Result of {@link SpacesClient.accept}. */\nexport interface AcceptResult extends AcceptInvitationResponse {\n\t/**\n\t * True when the membership is on the server but has not reached this device yet\n\t * (offline, or slower than `timeoutMs`). It arrives with the next sync.\n\t */\n\tpending: boolean\n}\n\n/** The sharing actions, each one request to the server. */\nexport interface SpacesClient {\n\t/** Invite `email` to `space` (or re-send their pending invitation with a new link). */\n\tinvite(space: SpaceRef, input: { email: string; role: string }): Promise<CreateInvitationResponse>\n\t/** Revoke a pending invitation. */\n\trevokeInvitation(invitationId: string): Promise<void>\n\t/** What the invitation behind `token` grants, for an accept page. */\n\tdescribe(token: string): Promise<InvitationDescription>\n\t/**\n\t * Accept the invitation behind `token`. Resolves once this device has the new\n\t * membership (so the space's data is on its way), or after `timeoutMs` (default 10 s)\n\t * with `pending: true`.\n\t */\n\taccept(token: string, options?: { timeoutMs?: number }): Promise<AcceptResult>\n\t/** Decline the invitation behind `token`. */\n\tdecline(token: string): Promise<void>\n\t/** Change a member's role. Pass `from` (the role you saw) to refuse if it changed meanwhile. */\n\tchangeRole(\n\t\tspace: SpaceRef,\n\t\tuserId: string,\n\t\trole: string,\n\t\toptions?: { from?: string },\n\t): Promise<MemberChangeResponse>\n\t/** Remove a member. */\n\tremove(\n\t\tspace: SpaceRef,\n\t\tuserId: string,\n\t\toptions?: { from?: string },\n\t): Promise<MemberChangeResponse>\n\t/** Leave a space (not the owner, who transfers it first). */\n\tleave(space: SpaceRef): Promise<MemberChangeResponse>\n\t/** Give the space to a member. You keep `previousOwner` (the manager role by default), or pass `'leave'`. */\n\ttransfer(\n\t\tspace: SpaceRef,\n\t\ttoUserId: string,\n\t\toptions?: { previousOwner?: string },\n\t): Promise<MemberChangeResponse>\n}\n\nconst DEFAULT_ACCEPT_TIMEOUT_MS = 10_000\n\n/**\n * The client of `spacesRoutes`.\n *\n * @param app - The Kora app (for `accept` to wait until the membership arrives)\n * @param options - Where the routes are and how to authenticate\n *\n * @example\n * ```typescript\n * const spaces = createSpaces(app, {\n * headers: async () => ({ authorization: `Bearer ${await auth.getAccessToken()}` }),\n * })\n * await spaces.invite(['forms', formId], { email: 'bob@example.com', role: 'editor' })\n * ```\n */\nexport function createSpaces(app: SpacesAppLike, options: SpacesClientOptions = {}): SpacesClient {\n\t// One leading slash, no trailing one, so a relative `api/spaces` never resolves\n\t// against the current page.\n\tconst path = `/${(options.basePath ?? '/api/spaces').replace(/^\\/+|\\/+$/g, '')}`\n\tif (path === '/') {\n\t\t// `//invitations/create` would name another host (and send it the auth headers).\n\t\tthrow new SpacesConfigError(\n\t\t\t'createSpaces needs a basePath below the root, such as \"/api/spaces\".',\n\t\t\t{\n\t\t\t\tbasePath: options.basePath,\n\t\t\t},\n\t\t)\n\t}\n\tconst base = `${options.baseUrl?.replace(/\\/+$/, '') ?? ''}${path}`\n\n\tconst post = async <T>(path: string, body: unknown): Promise<T> => {\n\t\tconst doFetch = options.fetch ?? globalThis.fetch\n\t\tlet response: Response\n\t\ttry {\n\t\t\tresponse = await doFetch(`${base}${path}`, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'content-type': 'application/json', ...(await options.headers?.()) },\n\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\tcredentials: 'same-origin',\n\t\t\t\treferrerPolicy: 'no-referrer',\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tthrow new SpacesOfflineError(error)\n\t\t}\n\t\tlet parsed: unknown = null\n\t\ttry {\n\t\t\tparsed = await response.json()\n\t\t} catch {\n\t\t\tparsed = null\n\t\t}\n\t\tif (response.ok && (parsed === null || typeof parsed !== 'object')) {\n\t\t\t// A success that is not our JSON (a captive portal, a proxy page): not the server.\n\t\t\tthrow new SpacesOfflineError(new Error(`Unexpected response from ${path}`))\n\t\t}\n\t\tif (!response.ok) {\n\t\t\tconst error = (parsed as { error?: { code?: string; message?: string } } | null)?.error\n\t\t\tif (!error?.code) {\n\t\t\t\t// Not our routes answering (a proxy, a captive portal): treat as unreachable.\n\t\t\t\tthrow new SpacesOfflineError(new Error(`HTTP ${response.status}`))\n\t\t\t}\n\t\t\tthrow new SpacesError(\n\t\t\t\terror.code as SpacesErrorCode,\n\t\t\t\terror.message ?? error.code,\n\t\t\t\tresponse.status,\n\t\t\t)\n\t\t}\n\t\treturn parsed as T\n\t}\n\n\t/**\n\t * Resolve true once this membership's row (its `joinedAt`, written by the accept) is on\n\t * this device. A row left from an earlier membership never counts, even with the same\n\t * id and role.\n\t */\n\tconst waitForMembership = (\n\t\tspace: SpaceRef,\n\t\tuserId: string,\n\t\tjoinedAt: number,\n\t\ttimeoutMs: number,\n\t): Promise<boolean> =>\n\t\tnew Promise((resolve) => {\n\t\t\tconst collection = app.collections[SPACE_MEMBERS]\n\t\t\tif (!collection) {\n\t\t\t\tresolve(false)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlet settled = false\n\t\t\tlet unsubscribe: (() => void) | null = null\n\t\t\tconst finish = (arrived: boolean): void => {\n\t\t\t\tif (settled) return\n\t\t\t\tsettled = true\n\t\t\t\tclearTimeout(timer)\n\t\t\t\tqueueMicrotask(() => unsubscribe?.())\n\t\t\t\tresolve(arrived)\n\t\t\t}\n\t\t\tconst timer = setTimeout(() => finish(false), timeoutMs)\n\t\t\tunsubscribe = collection\n\t\t\t\t.where({ group: groupKey(space[0], space[1]), userId })\n\t\t\t\t.subscribe((rows) => {\n\t\t\t\t\tif (rows.some((row) => row.joinedAt === joinedAt)) finish(true)\n\t\t\t\t})\n\t\t\tif (settled) unsubscribe()\n\t\t})\n\n\treturn {\n\t\tinvite: (space, input) =>\n\t\t\tpost('/invitations/create', { space, email: input.email, role: input.role }),\n\t\tasync revokeInvitation(invitationId) {\n\t\t\tawait post('/invitations/revoke', { invitationId })\n\t\t},\n\t\tdescribe: (token) => post('/invitations/describe', { token }),\n\t\tasync accept(token, acceptOptions) {\n\t\t\tconst accepted = await post<AcceptInvitationResponse>('/invitations/accept', { token })\n\t\t\tconst arrived = await waitForMembership(\n\t\t\t\taccepted.space,\n\t\t\t\taccepted.userId,\n\t\t\t\taccepted.joinedAt,\n\t\t\t\tacceptOptions?.timeoutMs ?? DEFAULT_ACCEPT_TIMEOUT_MS,\n\t\t\t)\n\t\t\treturn { ...accepted, pending: !arrived }\n\t\t},\n\t\tasync decline(token) {\n\t\t\tawait post('/invitations/decline', { token })\n\t\t},\n\t\tchangeRole: (space, userId, role, changeOptions) =>\n\t\t\tpost('/members/role', {\n\t\t\t\tspace,\n\t\t\t\tuserId,\n\t\t\t\trole,\n\t\t\t\t...(changeOptions?.from !== undefined ? { from: changeOptions.from } : {}),\n\t\t\t}),\n\t\tremove: (space, userId, removeOptions) =>\n\t\t\tpost('/members/remove', {\n\t\t\t\tspace,\n\t\t\t\tuserId,\n\t\t\t\t...(removeOptions?.from !== undefined ? { from: removeOptions.from } : {}),\n\t\t\t}),\n\t\tleave: (space) => post('/leave', { space }),\n\t\ttransfer: (space, toUserId, transferOptions) =>\n\t\t\tpost('/transfer', {\n\t\t\t\tspace,\n\t\t\t\ttoUserId,\n\t\t\t\t...(transferOptions?.previousOwner !== undefined\n\t\t\t\t\t? { previousOwner: transferOptions.previousOwner }\n\t\t\t\t\t: {}),\n\t\t\t}),\n\t}\n}\n","import type { AccessConfigInput, CollectionInput } from '@korajs/core'\nimport { KoraError, memberOfKey, serverOnly, t } from '@korajs/core'\n\n/** The memberships collection the kit owns (`userId`, `group`, `role`, `invitedBy`). */\nexport const SPACE_MEMBERS = 'space_members'\n/** Pending invitations, readable by the group's managers. */\nexport const SPACE_INVITATIONS = 'space_invitations'\n/** Invitation token hashes and the inviter's authority. Never leaves the server. */\nexport const SPACE_INVITATION_SECRETS = 'space_invitation_secrets'\n\n/** A group collection shared through the kit: which field of its records holds the owner. */\nexport interface SpacesGroupConfig {\n\t/** The owner field, usually `t.string().stamp('userId')`. */\n\towner: string\n}\n\n/** Input of {@link spacesCollections}. */\nexport interface SpacesConfig<Role extends string = string, Group extends string = string> {\n\t/** Roles, lowest to highest. The owner role must be the highest. */\n\troles: readonly Role[]\n\t/** The role a group's owner holds, through the group record's owner field only. */\n\townerRole: Role\n\t/** The lowest role that may invite, change roles and remove members. Below `ownerRole`. */\n\tmanageRole: Role\n\t/** The collections whose records are shared spaces (a form, a document, a board). */\n\tgroups: Readonly<Record<Group, SpacesGroupConfig>>\n}\n\n/** The kit's three collections, ready to spread into `defineSchema({ collections })`. */\nexport interface SpacesCollectionsInput {\n\tspace_members: CollectionInput\n\tspace_invitations: CollectionInput\n\tspace_invitation_secrets: CollectionInput\n}\n\n/**\n * The result of {@link spacesCollections}: the schema parts, and the configuration the\n * server routes and React hooks read. Pass the same object to `spacesRoutes({ sharing })`.\n */\nexport interface Sharing<Role extends string = string, Group extends string = string> {\n\treadonly roles: readonly Role[]\n\treadonly ownerRole: Role\n\treadonly manageRole: Role\n\treadonly groups: Readonly<Record<Group, SpacesGroupConfig>>\n\t/** `defineSchema({ access })`. */\n\treadonly access: AccessConfigInput\n\t/** Spread into `defineSchema({ collections })`. */\n\treadonly collections: SpacesCollectionsInput\n\t/** The rank of `role` (0 = lowest), or -1 when it is not a role. */\n\trank(role: string): number\n}\n\n/** Thrown when `spacesCollections` is given a configuration it cannot share safely. */\nexport class SpacesConfigError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'SPACES_CONFIG_INVALID', context)\n\t\tthis.name = 'SpacesConfigError'\n\t}\n}\n\n/**\n * Declare sharing for one or more group collections: the access configuration (the\n * memberships collection, the roles, which collections are groups) and the kit's\n * collections (members, pending invitations, invitation secrets). Every membership and\n * invitation is written by the kit's server routes; clients read them as synced data.\n *\n * @param config - Roles (lowest to highest), the owner and manager roles, and the group collections\n * @returns The schema parts and the configuration to hand to `spacesRoutes`\n * @throws {SpacesConfigError} when the roles or groups cannot be shared safely\n *\n * @example\n * ```typescript\n * export const sharing = spacesCollections({\n * roles: ['viewer', 'editor', 'admin', 'owner'],\n * ownerRole: 'owner',\n * manageRole: 'admin',\n * groups: { forms: { owner: 'ownerId' } },\n * })\n *\n * export default defineSchema({\n * version: 1,\n * access: sharing.access,\n * collections: { ...sharing.collections, forms: { ... } },\n * })\n * ```\n */\nexport function spacesCollections<const Role extends string, const Group extends string>(\n\tconfig: SpacesConfig<Role, Group>,\n): Sharing<Role, Group> {\n\tconst roles = [...config.roles]\n\tif (roles.length < 2) {\n\t\tthrow new SpacesConfigError(\n\t\t\t'spacesCollections needs at least two roles (a member role and the owner role).',\n\t\t\t{\n\t\t\t\troles,\n\t\t\t},\n\t\t)\n\t}\n\tconst seen = new Set<string>()\n\tfor (const role of roles) {\n\t\tif (typeof role !== 'string' || role.length === 0) {\n\t\t\tthrow new SpacesConfigError('Every role must be a non-empty string.', { roles })\n\t\t}\n\t\tif (seen.has(role)) throw new SpacesConfigError(`Role \"${role}\" is listed twice.`, { roles })\n\t\tif (role === 'leave') {\n\t\t\t// `transfer({ previousOwner: 'leave' })` means \"keep no role\".\n\t\t\tthrow new SpacesConfigError(\n\t\t\t\t'\"leave\" cannot be a role name: transfers use it to mean \"no role\".',\n\t\t\t\t{\n\t\t\t\t\troles,\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t\tseen.add(role)\n\t}\n\tconst ownerRank = roles.indexOf(config.ownerRole)\n\tconst manageRank = roles.indexOf(config.manageRole)\n\tif (ownerRank === -1) {\n\t\tthrow new SpacesConfigError(`ownerRole \"${config.ownerRole}\" is not one of the roles.`, {\n\t\t\troles,\n\t\t})\n\t}\n\tif (manageRank === -1) {\n\t\tthrow new SpacesConfigError(`manageRole \"${config.manageRole}\" is not one of the roles.`, {\n\t\t\troles,\n\t\t})\n\t}\n\t// Nobody may outrank the owner: the owner can act on every member, and the owner role is\n\t// held only through the group record's owner field (it moves only by transfer).\n\tif (ownerRank !== roles.length - 1) {\n\t\tthrow new SpacesConfigError(\n\t\t\t`ownerRole \"${config.ownerRole}\" must be the highest role (last in roles). Add an owner role above \"${roles[roles.length - 1]}\".`,\n\t\t\t{ roles, ownerRole: config.ownerRole },\n\t\t)\n\t}\n\tif (manageRank >= ownerRank) {\n\t\tthrow new SpacesConfigError(\n\t\t\t'manageRole must be below ownerRole: managers invite and manage members, and only the owner owns the space.',\n\t\t\t{ roles, ownerRole: config.ownerRole, manageRole: config.manageRole },\n\t\t)\n\t}\n\tconst groupEntries = Object.entries(config.groups) as Array<[Group, SpacesGroupConfig]>\n\tif (groupEntries.length === 0) {\n\t\tthrow new SpacesConfigError('spacesCollections needs at least one group collection.')\n\t}\n\tconst groups = {} as Record<Group, SpacesGroupConfig>\n\tconst accessGroups: Record<string, { owner: string; role: string }> = {}\n\tfor (const [collection, group] of groupEntries) {\n\t\tif (typeof group?.owner !== 'string' || group.owner.length === 0) {\n\t\t\tthrow new SpacesConfigError(`Group collection \"${collection}\" needs its owner field.`, {\n\t\t\t\tcollection,\n\t\t\t})\n\t\t}\n\t\tif (\n\t\t\tcollection === SPACE_MEMBERS ||\n\t\t\tcollection === SPACE_INVITATIONS ||\n\t\t\tcollection === SPACE_INVITATION_SECRETS\n\t\t) {\n\t\t\tthrow new SpacesConfigError(`\"${collection}\" is one of the kit's own collections.`, {\n\t\t\t\tcollection,\n\t\t\t})\n\t\t}\n\t\tgroups[collection] = Object.freeze({ owner: group.owner })\n\t\taccessGroups[collection] = { owner: group.owner, role: config.ownerRole }\n\t}\n\n\tconst collections: SpacesCollectionsInput = {\n\t\tspace_members: {\n\t\t\tfields: {\n\t\t\t\tuserId: t.string(),\n\t\t\t\tgroup: t.string(),\n\t\t\t\trole: t.string(),\n\t\t\t\tinvitedBy: t.string().optional(),\n\t\t\t\t/** When the member accepted their invitation (server time). */\n\t\t\t\tjoinedAt: t.timestamp().optional(),\n\t\t\t\texpiresAt: t.timestamp().optional(),\n\t\t\t},\n\t\t\tindexes: ['group', 'userId'],\n\t\t\t// Members see who else is in their spaces. Only the server writes (no write rule).\n\t\t\taccess: { read: memberOfKey('group') },\n\t\t},\n\t\tspace_invitations: {\n\t\t\tfields: {\n\t\t\t\tgroup: t.string(),\n\t\t\t\temail: t.string(),\n\t\t\t\trole: t.string(),\n\t\t\t\tinvitedBy: t.string(),\n\t\t\t\texpiresAt: t.timestamp(),\n\t\t\t\tcreatedAt: t.timestamp(),\n\t\t\t\ttokenVersion: t.number(),\n\t\t\t},\n\t\t\tindexes: ['group'],\n\t\t\t// Invitee emails are visible to the space's managers only.\n\t\t\taccess: { read: memberOfKey('group', config.manageRole) },\n\t\t},\n\t\tspace_invitation_secrets: {\n\t\t\tfields: {\n\t\t\t\tinvitationId: t.string(),\n\t\t\t\tgroup: t.string(),\n\t\t\t\ttokenHash: t.string(),\n\t\t\t\ttokenVersion: t.number(),\n\t\t\t\tinviterId: t.string(),\n\t\t\t\tauthority: t.string(),\n\t\t\t\townerVersion: t.string(),\n\t\t\t},\n\t\t\tindexes: ['group', 'inviterId'],\n\t\t\taccess: { read: serverOnly() },\n\t\t},\n\t}\n\n\tconst frozenRoles = Object.freeze(roles) as readonly Role[]\n\treturn Object.freeze({\n\t\troles: frozenRoles,\n\t\townerRole: config.ownerRole,\n\t\tmanageRole: config.manageRole,\n\t\tgroups: Object.freeze(groups),\n\t\taccess: Object.freeze({\n\t\t\tmemberships: SPACE_MEMBERS,\n\t\t\troles: frozenRoles,\n\t\t\tgroups: accessGroups,\n\t\t}),\n\t\tcollections,\n\t\trank: (role: string): number => frozenRoles.indexOf(role as Role),\n\t})\n}\n"],"mappings":"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","names":["import_core","path"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core';
|
|
2
|
+
import { A as AcceptInvitationResponse, S as SpaceRef, C as CreateInvitationResponse, I as InvitationDescription, M as MemberChangeResponse, a as SpacesErrorCode } from './protocol-DUU43t8s.cjs';
|
|
3
|
+
|
|
4
|
+
/** The parts of a Kora app the client reads: the local members collection. */
|
|
5
|
+
interface SpacesAppLike {
|
|
6
|
+
collections: Readonly<Record<string, {
|
|
7
|
+
where(conditions: Record<string, unknown>): {
|
|
8
|
+
subscribe(callback: (rows: readonly Record<string, unknown>[]) => void): () => void;
|
|
9
|
+
};
|
|
10
|
+
}>>;
|
|
11
|
+
}
|
|
12
|
+
/** Options of {@link createSpaces}. */
|
|
13
|
+
interface SpacesClientOptions {
|
|
14
|
+
/** Where `spacesRoutes` is mounted (default `/api/spaces`). */
|
|
15
|
+
basePath?: string;
|
|
16
|
+
/** The server origin, when the routes are not on the page's own origin (`https://api.example.com`). */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Headers for every request, usually the signed-in user's bearer token:
|
|
20
|
+
* `headers: async () => ({ authorization: \`Bearer ${await getAccessToken()}\` })`.
|
|
21
|
+
*/
|
|
22
|
+
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
|
23
|
+
/** A fetch implementation (default: the global `fetch`). */
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
/** A refusal from the spaces routes. `code` is stable; `message` is for people. */
|
|
27
|
+
declare class SpacesError extends KoraError {
|
|
28
|
+
readonly spacesCode: SpacesErrorCode | 'OFFLINE';
|
|
29
|
+
readonly status: number;
|
|
30
|
+
constructor(spacesCode: SpacesErrorCode | 'OFFLINE', message: string, status: number);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The server could not be reached. Sharing changes are server decisions, so nothing is
|
|
34
|
+
* queued: try again when online.
|
|
35
|
+
*/
|
|
36
|
+
declare class SpacesOfflineError extends SpacesError {
|
|
37
|
+
constructor(cause: unknown);
|
|
38
|
+
}
|
|
39
|
+
/** Result of {@link SpacesClient.accept}. */
|
|
40
|
+
interface AcceptResult extends AcceptInvitationResponse {
|
|
41
|
+
/**
|
|
42
|
+
* True when the membership is on the server but has not reached this device yet
|
|
43
|
+
* (offline, or slower than `timeoutMs`). It arrives with the next sync.
|
|
44
|
+
*/
|
|
45
|
+
pending: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** The sharing actions, each one request to the server. */
|
|
48
|
+
interface SpacesClient {
|
|
49
|
+
/** Invite `email` to `space` (or re-send their pending invitation with a new link). */
|
|
50
|
+
invite(space: SpaceRef, input: {
|
|
51
|
+
email: string;
|
|
52
|
+
role: string;
|
|
53
|
+
}): Promise<CreateInvitationResponse>;
|
|
54
|
+
/** Revoke a pending invitation. */
|
|
55
|
+
revokeInvitation(invitationId: string): Promise<void>;
|
|
56
|
+
/** What the invitation behind `token` grants, for an accept page. */
|
|
57
|
+
describe(token: string): Promise<InvitationDescription>;
|
|
58
|
+
/**
|
|
59
|
+
* Accept the invitation behind `token`. Resolves once this device has the new
|
|
60
|
+
* membership (so the space's data is on its way), or after `timeoutMs` (default 10 s)
|
|
61
|
+
* with `pending: true`.
|
|
62
|
+
*/
|
|
63
|
+
accept(token: string, options?: {
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
}): Promise<AcceptResult>;
|
|
66
|
+
/** Decline the invitation behind `token`. */
|
|
67
|
+
decline(token: string): Promise<void>;
|
|
68
|
+
/** Change a member's role. Pass `from` (the role you saw) to refuse if it changed meanwhile. */
|
|
69
|
+
changeRole(space: SpaceRef, userId: string, role: string, options?: {
|
|
70
|
+
from?: string;
|
|
71
|
+
}): Promise<MemberChangeResponse>;
|
|
72
|
+
/** Remove a member. */
|
|
73
|
+
remove(space: SpaceRef, userId: string, options?: {
|
|
74
|
+
from?: string;
|
|
75
|
+
}): Promise<MemberChangeResponse>;
|
|
76
|
+
/** Leave a space (not the owner, who transfers it first). */
|
|
77
|
+
leave(space: SpaceRef): Promise<MemberChangeResponse>;
|
|
78
|
+
/** Give the space to a member. You keep `previousOwner` (the manager role by default), or pass `'leave'`. */
|
|
79
|
+
transfer(space: SpaceRef, toUserId: string, options?: {
|
|
80
|
+
previousOwner?: string;
|
|
81
|
+
}): Promise<MemberChangeResponse>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The client of `spacesRoutes`.
|
|
85
|
+
*
|
|
86
|
+
* @param app - The Kora app (for `accept` to wait until the membership arrives)
|
|
87
|
+
* @param options - Where the routes are and how to authenticate
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const spaces = createSpaces(app, {
|
|
92
|
+
* headers: async () => ({ authorization: `Bearer ${await auth.getAccessToken()}` }),
|
|
93
|
+
* })
|
|
94
|
+
* await spaces.invite(['forms', formId], { email: 'bob@example.com', role: 'editor' })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
declare function createSpaces(app: SpacesAppLike, options?: SpacesClientOptions): SpacesClient;
|
|
98
|
+
|
|
99
|
+
export { type AcceptResult, type SpacesAppLike, type SpacesClient, type SpacesClientOptions, SpacesError, SpacesOfflineError, createSpaces };
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core';
|
|
2
|
+
import { A as AcceptInvitationResponse, S as SpaceRef, C as CreateInvitationResponse, I as InvitationDescription, M as MemberChangeResponse, a as SpacesErrorCode } from './protocol-DUU43t8s.js';
|
|
3
|
+
|
|
4
|
+
/** The parts of a Kora app the client reads: the local members collection. */
|
|
5
|
+
interface SpacesAppLike {
|
|
6
|
+
collections: Readonly<Record<string, {
|
|
7
|
+
where(conditions: Record<string, unknown>): {
|
|
8
|
+
subscribe(callback: (rows: readonly Record<string, unknown>[]) => void): () => void;
|
|
9
|
+
};
|
|
10
|
+
}>>;
|
|
11
|
+
}
|
|
12
|
+
/** Options of {@link createSpaces}. */
|
|
13
|
+
interface SpacesClientOptions {
|
|
14
|
+
/** Where `spacesRoutes` is mounted (default `/api/spaces`). */
|
|
15
|
+
basePath?: string;
|
|
16
|
+
/** The server origin, when the routes are not on the page's own origin (`https://api.example.com`). */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Headers for every request, usually the signed-in user's bearer token:
|
|
20
|
+
* `headers: async () => ({ authorization: \`Bearer ${await getAccessToken()}\` })`.
|
|
21
|
+
*/
|
|
22
|
+
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
|
23
|
+
/** A fetch implementation (default: the global `fetch`). */
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
/** A refusal from the spaces routes. `code` is stable; `message` is for people. */
|
|
27
|
+
declare class SpacesError extends KoraError {
|
|
28
|
+
readonly spacesCode: SpacesErrorCode | 'OFFLINE';
|
|
29
|
+
readonly status: number;
|
|
30
|
+
constructor(spacesCode: SpacesErrorCode | 'OFFLINE', message: string, status: number);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The server could not be reached. Sharing changes are server decisions, so nothing is
|
|
34
|
+
* queued: try again when online.
|
|
35
|
+
*/
|
|
36
|
+
declare class SpacesOfflineError extends SpacesError {
|
|
37
|
+
constructor(cause: unknown);
|
|
38
|
+
}
|
|
39
|
+
/** Result of {@link SpacesClient.accept}. */
|
|
40
|
+
interface AcceptResult extends AcceptInvitationResponse {
|
|
41
|
+
/**
|
|
42
|
+
* True when the membership is on the server but has not reached this device yet
|
|
43
|
+
* (offline, or slower than `timeoutMs`). It arrives with the next sync.
|
|
44
|
+
*/
|
|
45
|
+
pending: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** The sharing actions, each one request to the server. */
|
|
48
|
+
interface SpacesClient {
|
|
49
|
+
/** Invite `email` to `space` (or re-send their pending invitation with a new link). */
|
|
50
|
+
invite(space: SpaceRef, input: {
|
|
51
|
+
email: string;
|
|
52
|
+
role: string;
|
|
53
|
+
}): Promise<CreateInvitationResponse>;
|
|
54
|
+
/** Revoke a pending invitation. */
|
|
55
|
+
revokeInvitation(invitationId: string): Promise<void>;
|
|
56
|
+
/** What the invitation behind `token` grants, for an accept page. */
|
|
57
|
+
describe(token: string): Promise<InvitationDescription>;
|
|
58
|
+
/**
|
|
59
|
+
* Accept the invitation behind `token`. Resolves once this device has the new
|
|
60
|
+
* membership (so the space's data is on its way), or after `timeoutMs` (default 10 s)
|
|
61
|
+
* with `pending: true`.
|
|
62
|
+
*/
|
|
63
|
+
accept(token: string, options?: {
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
}): Promise<AcceptResult>;
|
|
66
|
+
/** Decline the invitation behind `token`. */
|
|
67
|
+
decline(token: string): Promise<void>;
|
|
68
|
+
/** Change a member's role. Pass `from` (the role you saw) to refuse if it changed meanwhile. */
|
|
69
|
+
changeRole(space: SpaceRef, userId: string, role: string, options?: {
|
|
70
|
+
from?: string;
|
|
71
|
+
}): Promise<MemberChangeResponse>;
|
|
72
|
+
/** Remove a member. */
|
|
73
|
+
remove(space: SpaceRef, userId: string, options?: {
|
|
74
|
+
from?: string;
|
|
75
|
+
}): Promise<MemberChangeResponse>;
|
|
76
|
+
/** Leave a space (not the owner, who transfers it first). */
|
|
77
|
+
leave(space: SpaceRef): Promise<MemberChangeResponse>;
|
|
78
|
+
/** Give the space to a member. You keep `previousOwner` (the manager role by default), or pass `'leave'`. */
|
|
79
|
+
transfer(space: SpaceRef, toUserId: string, options?: {
|
|
80
|
+
previousOwner?: string;
|
|
81
|
+
}): Promise<MemberChangeResponse>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The client of `spacesRoutes`.
|
|
85
|
+
*
|
|
86
|
+
* @param app - The Kora app (for `accept` to wait until the membership arrives)
|
|
87
|
+
* @param options - Where the routes are and how to authenticate
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const spaces = createSpaces(app, {
|
|
92
|
+
* headers: async () => ({ authorization: `Bearer ${await auth.getAccessToken()}` }),
|
|
93
|
+
* })
|
|
94
|
+
* await spaces.invite(['forms', formId], { email: 'bob@example.com', role: 'editor' })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
declare function createSpaces(app: SpacesAppLike, options?: SpacesClientOptions): SpacesClient;
|
|
98
|
+
|
|
99
|
+
export { type AcceptResult, type SpacesAppLike, type SpacesClient, type SpacesClientOptions, SpacesError, SpacesOfflineError, createSpaces };
|