create-qpq-app 0.1.21 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-qpq-app",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.13.13",
58
- "quidproquo-tsconfig": "0.1.21"
58
+ "quidproquo-tsconfig": "0.1.22"
59
59
  },
60
60
  "bin": {
61
61
  "create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
@@ -10,7 +10,7 @@ Declares the **owner-only stores** that back the tenant (org) feature, without a
10
10
  1. The **tenant event-doc collection**: a [defineEventDocSummary](./event-doc-summary.md) call for the `tenants` store (summary table, append-only event log, and asset drive). This is the audit-trailed source of truth for tenant state.
11
11
  2. The **materialized tenant record store**: a [key-value store](../core/key-value-store.md) named `tenantRecords` (partition key `tenantId`). A fast-read table synced from the event doc on publish; it is never written directly by request handlers.
12
12
 
13
- The **user-tenant membership links store** (`userTenantLinks`, partition key `userId`) is declared separately, directly by [defineTenant](./tenant.md) — every service refs it via the same `owner`, so it isn't part of this helper.
13
+ The **tenant memberships store** (`tenantMemberships`, partition key `userId`, sort key `tenantId`, with a GSI on `tenantId`/`userId`) is declared separately, directly by [defineTenant](./tenant.md) — every service refs it via the same `owner`, so it isn't part of this helper. One row per `(user, tenant)` link serves both the access check (a keyed get) and, via the GSI, a tenant's member list.
14
14
 
15
15
  - **On AWS:** deploys everything [defineEventDocSummary](./event-doc-summary.md) deploys (two DynamoDB tables plus an S3 bucket), plus one more DynamoDB table (via [defineKeyValueStore](../core/key-value-store.md)) for the record store.
16
16
 
@@ -39,7 +39,7 @@ None. All store names are fixed constants exported from `quidproquo-features`:
39
39
  | `TENANT_EVENTDOC_STORE` | `'tenants'` | The tenant event-doc collection. |
40
40
  | `TENANT_RECORD_STORE` | `'tenantRecords'` | The materialized tenant record table. |
41
41
 
42
- The membership table's constant, `USER_TENANT_LINKS_STORE` (`'userTenantLinks'`), is also exported, but the store itself is declared by [defineTenant](./tenant.md), not here.
42
+ The membership table's constant, `TENANT_MEMBERSHIPS_STORE` (`'tenantMemberships'`), is also exported, but the store itself is declared by [defineTenant](./tenant.md), not here.
43
43
 
44
44
  ## Store row shapes
45
45
 
@@ -58,19 +58,25 @@ type TenantRecord = {
58
58
  };
59
59
  ```
60
60
 
61
- The membership store holds `UserTenantLinks` rows:
61
+ The membership store holds `TenantMembership` rows, one per `(user, tenant)` link:
62
62
 
63
63
  ```typescript
64
- type UserTenantLinks = {
64
+ type TenantMembership = {
65
+ tenantId: string;
65
66
  userId: string;
66
- tenantIds: string[];
67
+ role: TenantMembershipRole;
68
+ disabled?: boolean;
69
+ joinedAt: QpqIsoDateTime;
70
+ addedByUserId: string;
67
71
  };
68
72
  ```
69
73
 
74
+ `role` is `TenantMembershipRole.owner` or `TenantMembershipRole.member` — see [defineTenant](./tenant.md#routes-mounted) for who can change it. A `disabled` member fails the membership check everywhere (scope resolver, WebSocket scope, tenant routes) but still appears in the member list, so an owner can re-enable them.
75
+
70
76
  ## Notes
71
77
 
72
78
  - The tenant event-doc collection is the source of truth; the `tenantRecords` table is a read model. The sync between them is the `askTenantOnPublish` inline function, which [defineTenant](./tenant.md) registers and wires into the collection's `onPublish` hook.
73
- - Creating a tenant appends its id to the caller's `UserTenantLinks` row, so the creator becomes the tenant's first member.
79
+ - Creating a tenant writes a `TenantMembership` row with `role: owner` for the caller, so the creator becomes the tenant's first member. The same table serves both the access check (`userId`+`tenantId` keyed get) and, via its `tenantId`/`userId` GSI, a tenant's member list — there is no separate reverse index to keep in lock-step.
74
80
  - Services that do **not** own these stores still call [defineTenant](./tenant.md) (with the same `owner`) to get the scope resolver and a cross-module reference to the membership table — they never call `defineTenantStores` themselves.
75
81
 
76
82
  ## Related
@@ -8,14 +8,14 @@ description: Wire up org/tenant support across every service in one call — the
8
8
  Wires up **everything for org/tenant support**, declared identically in every service that needs it. You always pass the same `owner`; what actually materializes depends on whether the current module is that owner. It returns a `QPQConfig` (an array of config settings) composed of:
9
9
 
10
10
  - The scope-resolver and connection-scope-resolver [inline functions](../core/inline-function.md) (`TENANT_SCOPE_RESOLVER_FN`, `TENANT_CONNECTION_SCOPE_RESOLVER_FN`) — registered in **every** service, so each can tenant-scope its own collections and WebSocket connections. The tenant collection itself is an ordinary tenanted collection too: a tenant doc lives in whatever scope the request that created it ran under (the creator's personal partition, or the active tenant when an org creates a sub-tenant), and every tenant route resolves its scope with the same resolver. The cross-scope registry surface is the membership table plus the materialized `TenantRecord` store below, both unscoped.
11
- - The `userTenantLinks` membership table ([key-value store](../core/key-value-store.md)) — declared with `owner` everywhere, so non-owner services get a cross-module reference to the owner's table instead of their own copy.
11
+ - The `tenantMemberships` membership table ([key-value store](../core/key-value-store.md)) — declared with `owner` everywhere, so non-owner services get a cross-module reference to the owner's table instead of their own copy.
12
12
  - Everything else, gated to the owner's deploy only via [defineServiceSettings](../core/service-settings.md):
13
13
  - The tenant stores ([defineTenantStores](./tenant-stores.md)): the tenant event-doc collection plus the materialized record table.
14
14
  - The publish-to-record-store sync: an inline function (`askTenantOnPublish`) that runs on every published tenant document, re-folds the full event log, and upserts the resulting `TenantRecord`. It is a plain upsert of the fold result, so publish retries and repair re-runs are safe.
15
15
  - The stock event-doc CRUD at `{basePath}` ([defineEventDocRoutes](./event-doc-routes.md) for the `tenants` store, `tenant` type, with the publish sync wired in as `onPublish`, and `create` excluded — see below).
16
- - The membership-gated routes at `{myTenantsBasePath}`: list my tenants, create, get record, and get logo.
16
+ - The membership-gated routes at `{myTenantsBasePath}`: list my tenants, create, get record, get logo, and manage members (list, add, remove).
17
17
 
18
- - **On AWS:** on the owner's deploy, this deploys everything [defineTenantStores](./tenant-stores.md) deploys (two DynamoDB tables and an S3 bucket) plus the API Gateway routes and Lambda handlers from [defineEventDocRoutes](./event-doc-routes.md) and the four tenant routes below. On every other service's deploy, only the `userTenantLinks` reference resolves (no new table); the inline functions deploy no infrastructure of their own anywhere.
18
+ - **On AWS:** on the owner's deploy, this deploys everything [defineTenantStores](./tenant-stores.md) deploys (two DynamoDB tables and an S3 bucket) plus the `tenantMemberships` table and the API Gateway routes and Lambda handlers from [defineEventDocRoutes](./event-doc-routes.md) and the tenant routes below. On every other service's deploy, only the `tenantMemberships` reference resolves (no new table); the inline functions deploy no infrastructure of their own anywhere.
19
19
 
20
20
  ```typescript
21
21
  import { defineTenant } from 'quidproquo-features';
@@ -42,6 +42,10 @@ All paths are prefixed with the version segment `/v{version}` (default `/v1`) an
42
42
  | `POST` | `{myTenantsBasePath}` | Create a tenant (body `{ name }`); the caller becomes its first member. Runs under the request's scope, so the new tenant doc lands in the caller's current partition. This is the only way to create a tenant — the stock `create` route is excluded at `{basePath}` so a new tenant is never made without also linking its creator. |
43
43
  | `GET` | `{myTenantsBasePath}/{id}` | One tenant's materialized record (the fast path). Members only: non-members get `Forbidden`, a missing record gets `NotFound`. |
44
44
  | `GET` | `{myTenantsBasePath}/{id}/logo` | A presigned, short-lived URL for the tenant's logo blob. Members only: non-members get `Forbidden`; a missing record or a tenant with no logo gets `NotFound`. Presigned in the scope the tenant doc was published under (recorded on the `TenantRecord`), not the reader's own scope, since the logo asset lives in the doc's home partition. |
45
+ | `GET` | `{myTenantsBasePath}/{id}/members` | The tenant's members as `TenantMember[]` (`userId`, `email`, `name`, hydrated from the user directory). Members only: non-members get `Forbidden`. |
46
+ | `POST` | `{myTenantsBasePath}/{id}/members` | Add an existing user directory account to the tenant (body `{ email }`). Owner only: non-owners get `Forbidden`. No invite flow — an email with no matching account gets `NotFound`. Idempotent: re-adding a member is a no-op that still returns the `TenantMember`. |
47
+ | `PATCH` | `{myTenantsBasePath}/{id}/members/{userId}` | Change a member's `role` and/or `disabled` flag (body a `TenantMemberUpdateRequest`, either field optional — omitted fields keep their current value). Owner only: non-owners get `Forbidden`; an unknown `userId` gets `NotFound`. A caller can never demote or disable themself: `BadRequest`. |
48
+ | `DELETE` | `{myTenantsBasePath}/{id}/members/{userId}` | Remove a member from the tenant. Owner only: non-owners get `Forbidden`. The owner can never be removed, even by themself: `BadRequest`. |
45
49
 
46
50
  On top of these, the stock event-doc route set (get, append, list events, assets, remove, and so on — everything but `create`) is mounted at `{basePath}`, named after the model type like any other collection; see [defineEventDocRoutes](./event-doc-routes.md#routes-mounted) for the list. `{basePath}` and `{myTenantsBasePath}` must be distinct and neither may be a path segment under the other: `{basePath}/{id}` matches any single segment, so a literal sibling path would be ambiguous with a tenant whose id happens to match it.
47
51
 
@@ -72,6 +76,7 @@ The single `options` argument is a `TenantOptions` (a `TenantRoutesOptions` plus
72
76
  - `defineTenant` registers the scope resolver but does not apply it to anything. To tenant-scope one of your own collections, pass `TENANT_SCOPE_RESOLVER_FN` as that collection's `scopeResolver` option (or use [defineTenantedEventDoc](./tenanted-event-doc.md), which does this for you).
73
77
  - The scope resolver always resolves to a typed scope — a membership-checked `TENANT#<id>` for a request that names a tenant, or the caller's own `PERSONAL#<userId>` when it doesn't. A tenant-scoped collection or connection is never left unscoped.
74
78
  - Every service — owner and non-owner alike — calls `defineTenant` with the same `owner`. There is no separate call for non-owning services anymore: the gating happens internally via [defineServiceSettings](../core/service-settings.md).
79
+ - Membership rows carry a `TenantMembershipRole` (`owner` or `member`, see [defineTenantStores](./tenant-stores.md)): the tenant's creator starts as `owner`. Owners manage membership (add/remove/update role or disabled); members merely belong. A single `tenantMemberships` table (keyed `userId`+`tenantId`, with a `tenantId`/`userId` GSI) serves both the access check and a tenant's member list, so there is no separate index to keep in lock-step.
75
80
 
76
81
  ## Related
77
82