Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d6365aa
feat: emit user_creation and tenant_disassociation lifecycle events
Aug 30, 2026
c03bba4
feat: serve approximate user counts from the lifecycle-event fold
Aug 30, 2026
a971ae9
feat: serve default user counts as anchor plus ledger fold
Aug 30, 2026
d4dff4c
docs: condense approximate-count changelog entry to a one-liner
Aug 30, 2026
cda62be
test: pin param-less approximate/asOf fields for exact-fallback count…
Aug 30, 2026
63892a9
test: lock serve()-level unlink (+1) and corrupt-payload re-anchor
Aug 30, 2026
9da1fb3
test: pin webauthn and fake-email user_creation emits; clarify tenant…
Aug 30, 2026
7097d1a
feat: emit user_import lifecycle events from bulk import
Aug 31, 2026
8b7e99d
Merge remote-tracking branch 'origin/feat/activity-log' into agent/is…
Aug 31, 2026
a70823c
Merge remote-tracking branch 'origin/agent/issue-1378-ledger-fold' in…
Aug 31, 2026
145a376
feat: replace user_last_active event with semantic activity events
Aug 31, 2026
9957bcb
docs: condense semantic-activity-events changelog entry to a one-liner
Aug 31, 2026
2e1886b
feat: write semantic activity events on a fail-loud audited transaction
Aug 31, 2026
35f8610
fix: keep semantic activity events best-effort, make the throttle con…
Aug 31, 2026
086fcb3
Merge pull request #1405 from supertokens/agent/issue-1379-default-flip
tamassoltesz Aug 31, 2026
09ff249
Merge branch 'agent/issue-1397-emit-creation-disassoc' into agent/iss…
tamassoltesz Aug 31, 2026
cf0c425
Merge pull request #1404 from supertokens/agent/issue-1378-ledger-fold
tamassoltesz Aug 31, 2026
da767fe
fix: wake the last-active rollup on user_creation and account_linking
Aug 31, 2026
8ce64d1
test: cover cross-login-method tenant union in bulk-import user_import
Aug 31, 2026
65aaf78
refactor: consume shared plugin-interface activity-event vocabulary
Sep 1, 2026
37b2520
test: pin the account_linking rollup nudge with a link-only promptnes…
Sep 1, 2026
0712835
Merge pull request #1408 from supertokens/agent/issue-1407-semantic-a…
tamassoltesz Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `ActiveUsers.updateLastActive` no longer writes `user_last_active` directly; it only appends the throttled `user_last_active` activity-log event, and the `RollupUserLastActive` cron is now the sole writer of the projection (so counts reflect activity within a rollup interval).
- Added Phase-1 parity tests proving the last-active rollup derives the same `countUsersActiveSince` answer (and per-user projection) as the direct write, including link/unlink cases (`ActivityLogRollupParityTest`).
- Adds an observability-only shadow audit to the approximate-user-count background refresh; discrepancies are logged and emitted as telemetry, never served.
- The `allowApproximate` user-count path now serves the exact anchor plus a fold of lifecycle events since the anchor, making the approximate count exact for creations, deletions and account (un)linking (no API change).
- From CDI 5.6 the default single-tenant, unfiltered `/users/count` path serves `anchor + fold` from the lifecycle-event ledger instead of an exact recompute per request, always returning the `approximate`/`asOf` fields and making the `allowApproximate` parameter a no-op; older CDI versions are unchanged.
- Emits `user_creation` and `tenant_disassociation` lifecycle events atomically with the mutation from the interactive user-creation and tenant-removal paths.
- Bulk import now emits lifecycle events atomically with the import: one `user_import` per imported user (counted toward user totals like `user_creation`, but under its own type so the last-active rollup can exclude imports) plus a `tenant_association` for each remaining tenant the user lands in.
- The last-active rollup fold now skips activity for apps no longer present in `apps`, so a deleted app's retained `activity_log` rows can never resurrect a `user_last_active` projection row (which would violate its `apps` foreign key).

## [12.2.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
* derived here from those lists, per {@link LifecycleEventType}:
*
* <ul>
* <li>{@code USER_CREATION} — {@code +1} for the created-in tenant (a new standalone group).</li>
* <li>{@code USER_CREATION} / {@code USER_IMPORT} — {@code +1} for the tenant the (new or imported) user
* lands in. The two are folded identically here; the distinction is for the last-active rollup, not the
* user count.</li>
* <li>{@code USER_GROUP_DELETION} — {@code -1} for every tenant the group was present in.</li>
* <li>{@code USER_DELETION} — {@code after − before} per tenant: {@code -1} for each tenant the group left
* (its last member there was deleted) and {@code +1} for any it newly appears in (not expected for a
Expand Down Expand Up @@ -147,6 +149,10 @@ public static long computeDeltaForTenant(List<LifecycleEventPayload> events, Str
private static void applyEvent(LifecycleEventPayload event, Map<String, Long> deltas) {
switch (event.type) {
case USER_CREATION:
case USER_IMPORT:
// A bulk-imported user is counted toward totals exactly like an interactively created one:
// a +1 in the tenant it lands in. (The type distinction matters only to the last-active
// rollup, which reads user_last_active/account_linking rows and never enters this fold.)
add(deltas, event.tenantId, 1);
break;
case USER_GROUP_DELETION:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ public static AuditLogEvent forTenantAssociation(AppIdentifier appIdentifier, St
LifecycleEventPayload.forTenantAssociation(groupBefore, tenantId), createdAt);
}

/**
* A {@code tenant_disassociation} event: the group identified by {@code groupUserId} (via member
* {@code recipeUserId}) was removed from tenant {@code tenantId}. The payload carries the group's presence
* list before the disassociation plus the tenant it was removed from.
*/
public static AuditLogEvent forTenantDisassociation(AppIdentifier appIdentifier, String recipeUserId,
String groupUserId, GroupPresence groupBefore, String tenantId, long createdAt) {
return build(appIdentifier, recipeUserId, groupUserId,
LifecycleEventPayload.forTenantDisassociation(groupBefore, tenantId), createdAt);
}

/**
* A {@code user_creation} event: a new recipe user {@code recipeUserId} was created in tenant
* {@code tenantId}. A freshly created user is its own group, so it is recorded against itself as both the
* recipe user and the group; the payload carries only the tenant it was created in (the group's presence
* afterwards is exactly that single tenant, derivable read-side without a stored list).
*/
public static AuditLogEvent forUserCreation(AppIdentifier appIdentifier, String recipeUserId,
String tenantId, long createdAt) {
return build(appIdentifier, recipeUserId, recipeUserId,
LifecycleEventPayload.forUserCreation(tenantId), createdAt);
}

/**
* A {@code user_import} event: the user identified by {@code groupUserId} was brought in by bulk import
* and lands in tenant {@code tenantId}. Counted toward user totals exactly like {@code user_creation}, so
* (like a freshly created user) the group is recorded against itself as both the recipe user and the
* group and the payload carries only the tenant. The distinct type is what lets a downstream consumer
* exclude imports from the last-active rollup while still counting them.
*/
public static AuditLogEvent forUserImport(AppIdentifier appIdentifier, String groupUserId,
String tenantId, long createdAt) {
return build(appIdentifier, groupUserId, groupUserId,
LifecycleEventPayload.forUserImport(tenantId), createdAt);
}

private static AuditLogEvent build(AppIdentifier appIdentifier, String recipeUserId,
String primaryOrRecipeUserId, LifecycleEventPayload payload, long createdAt) {
return new AuditLogEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
* derivable from before-presence plus the deleted member).</li>
* <li>{@code USER_GROUP_DELETION} — the group's before list only.</li>
* <li>{@code TENANT_ASSOCIATION} / {@code TENANT_DISASSOCIATION} — the group's before list plus the tenant.</li>
* <li>{@code USER_CREATION} — the tenant.</li>
* <li>{@code USER_CREATION} / {@code USER_IMPORT} — the tenant.</li>
* </ul>
*
* <p>Construct payloads through the {@code for*} factory methods (the one builder emit sites use) and read
Expand Down Expand Up @@ -78,7 +78,10 @@ public class LifecycleEventPayload {
public final GroupPresence groupBefore;
/** {@code USER_DELETION}: the group's after-list; otherwise {@code null}. */
public final GroupPresence groupAfter;
/** {@code TENANT_ASSOCIATION} / {@code TENANT_DISASSOCIATION} / {@code USER_CREATION}: the tenant. */
/**
* {@code TENANT_ASSOCIATION} / {@code TENANT_DISASSOCIATION} / {@code USER_CREATION} / {@code USER_IMPORT}:
* the tenant.
*/
public final String tenantId;

private LifecycleEventPayload(LifecycleEventType type, List<GroupPresence> groupsBefore,
Expand Down Expand Up @@ -144,6 +147,11 @@ public static LifecycleEventPayload forUserCreation(String tenantId) {
return new LifecycleEventPayload(LifecycleEventType.USER_CREATION, null, null, null, null, null, tenantId);
}

public static LifecycleEventPayload forUserImport(String tenantId) {
requireNonEmpty(tenantId, "tenantId");
return new LifecycleEventPayload(LifecycleEventType.USER_IMPORT, null, null, null, null, null, tenantId);
}

// ---- Serialization ----

/** @return the JSON string to store in the {@code activity_log.payload} column. */
Expand Down Expand Up @@ -177,6 +185,7 @@ public String toJson() {
json.addProperty(TENANT_ID_KEY, tenantId);
break;
case USER_CREATION:
case USER_IMPORT:
json.addProperty(TENANT_ID_KEY, tenantId);
break;
}
Expand Down Expand Up @@ -296,6 +305,9 @@ private static LifecycleEventPayload fromJsonObject(JsonObject json)
case USER_CREATION:
requireKeys(json, "payload", VERSION_KEY, TYPE_KEY, TENANT_ID_KEY);
return forUserCreation(parseTenantId(json));
case USER_IMPORT:
requireKeys(json, "payload", VERSION_KEY, TYPE_KEY, TENANT_ID_KEY);
return forUserImport(parseTenantId(json));
default:
// Unreachable: every LifecycleEventType is handled above.
throw new InvalidLifecycleEventPayloadException("unhandled lifecycle event type: " + type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ public enum LifecycleEventType {
TENANT_DISASSOCIATION("tenant_disassociation"),

/** A new user is created in a tenant. */
USER_CREATION("user_creation");
USER_CREATION("user_creation"),

/**
* A user is brought in by bulk import rather than created interactively. Counted toward user totals
* exactly like {@link #USER_CREATION} (a +1 in the tenant it lands in), but recorded under its own
* type so an imported user is distinguishable from an organically created one and is excluded from the
* last-active rollup (which an interactive sign-up may separately feed, but an import must not: an
* imported user is present, not active).
*/
USER_IMPORT("user_import");

private final String value;

Expand Down
Loading
Loading