@koolbase/core 11.4.0 → 12.0.0

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/esm/auth.js CHANGED
@@ -130,6 +130,22 @@ export class KoolbaseAuth {
130
130
  return this.authRequest(path, { ...options, includeAuth: true });
131
131
  }
132
132
  // ─── Internal session lifecycle ─────────────────────────────────────────
133
+ /**
134
+ * Replace the user inside the current session and persist it.
135
+ *
136
+ * A profile update or a fresh fetch changes the user, not the tokens.
137
+ * Without this the new values live only in the returned object: the cached
138
+ * `currentUser` stays stale, anything bound to `onAuthStateChange` never
139
+ * hears about the change, and a reload restores the old name.
140
+ *
141
+ * No session means nobody is signed in, and there is nothing to update —
142
+ * the caller's own request would have failed first.
143
+ */
144
+ async updateUserAndPersist(user) {
145
+ if (!this.session)
146
+ return;
147
+ await this.setSessionInternal({ ...this.session, user });
148
+ }
133
149
  async setSessionInternal(session) {
134
150
  this.session = session;
135
151
  if (this.storage) {
@@ -583,6 +599,188 @@ export class KoolbaseAuth {
583
599
  cooldownUntil: body.cooldown_until ? new Date(body.cooldown_until) : null,
584
600
  };
585
601
  }
602
+ /**
603
+ * The signed-in user, fetched fresh from the server.
604
+ *
605
+ * `currentUser` returns what was cached at sign-in; this asks. Use it when
606
+ * something may have changed the user server-side — a verification
607
+ * completing, a profile updated from another device — and persist the
608
+ * result so the cached copy stops being stale.
609
+ */
610
+ async getCurrentUser() {
611
+ const res = await this.authRequest('/v1/sdk/auth/me', {
612
+ method: 'GET',
613
+ includeAuth: true,
614
+ });
615
+ await this.checkResponse(res);
616
+ const user = this.mapUser(await res.json());
617
+ await this.updateUserAndPersist(user);
618
+ return user;
619
+ }
620
+ /**
621
+ * Update the signed-in user's profile.
622
+ *
623
+ * Only the fields given are sent, so passing one leaves the other
624
+ * untouched. The updated user is persisted, so `currentUser` reflects it
625
+ * without a refetch.
626
+ */
627
+ async updateProfile(params) {
628
+ const body = {};
629
+ if (params.fullName !== undefined)
630
+ body.full_name = params.fullName;
631
+ if (params.avatarUrl !== undefined)
632
+ body.avatar_url = params.avatarUrl;
633
+ const res = await this.authRequest('/v1/sdk/auth/me', {
634
+ method: 'PATCH',
635
+ body,
636
+ includeAuth: true,
637
+ });
638
+ await this.checkResponse(res);
639
+ const user = this.mapUser(await res.json());
640
+ await this.updateUserAndPersist(user);
641
+ return user;
642
+ }
643
+ /**
644
+ * Change the signed-in user's password.
645
+ *
646
+ * The current password is required: a stolen session should not be enough
647
+ * to lock the real owner out. A wrong one throws
648
+ * CurrentPasswordIncorrectError — the server returns `invalid_password`,
649
+ * which reads like a rejected new password and means the opposite.
650
+ *
651
+ * An account that signed up through Google or Apple has no password to
652
+ * change and gets the same error, deliberately, so a caller cannot probe
653
+ * which sign-in methods an account has.
654
+ */
655
+ async changePassword(params) {
656
+ if (params.newPassword.length < 8)
657
+ throw new WeakPasswordError();
658
+ const res = await this.authRequest('/v1/sdk/auth/me/password', {
659
+ method: 'PATCH',
660
+ body: {
661
+ current_password: params.currentPassword,
662
+ new_password: params.newPassword,
663
+ },
664
+ includeAuth: true,
665
+ });
666
+ await this.checkResponse(res);
667
+ }
668
+ /**
669
+ * Delete the signed-in user's account, and sign out.
670
+ *
671
+ * Deletes the caller only — there is no user id to pass, which is the
672
+ * security property. The server removes the auth record and every session
673
+ * on every device, then fires an `auth.user.deleted` trigger.
674
+ *
675
+ * It does NOT delete the account's records in your collections. Koolbase
676
+ * cannot know whether a row should be deleted, anonymised, or kept for a
677
+ * statutory retention period. Subscribe a Function to `auth.user.deleted`
678
+ * and clean up there — it runs server-side, so a tab closing mid-flow
679
+ * cannot strand anything.
680
+ */
681
+ async deleteAccount() {
682
+ const res = await this.authRequest('/v1/sdk/auth/me', {
683
+ method: 'DELETE',
684
+ includeAuth: true,
685
+ });
686
+ await this.checkResponse(res);
687
+ await this.clearSessionInternal();
688
+ }
689
+ /**
690
+ * Every session the signed-in user has — a "where you're signed in" list.
691
+ *
692
+ * Newest first is not guaranteed; sort by createdAt if the order matters.
693
+ * The entry with `isCurrent` is this device.
694
+ */
695
+ async listSessions() {
696
+ const res = await this.authRequest('/v1/sdk/auth/sessions', {
697
+ method: 'GET',
698
+ includeAuth: true,
699
+ });
700
+ await this.checkResponse(res);
701
+ const body = (await res.json());
702
+ return (body.sessions ?? []).map((s) => {
703
+ const r = s;
704
+ return {
705
+ id: String(r.id),
706
+ ip: r.ip,
707
+ userAgent: r.user_agent,
708
+ deviceLabel: r.device_label,
709
+ createdAt: String(r.created_at),
710
+ expiresAt: String(r.expires_at),
711
+ isCurrent: Boolean(r.is_current),
712
+ };
713
+ });
714
+ }
715
+ /**
716
+ * Sign out one session by id.
717
+ *
718
+ * Revoking the current one ends this session too — the server does not
719
+ * refuse it, and the local session is cleared here so the app does not keep
720
+ * a token the server has dropped. Use logout() when that is what you mean.
721
+ */
722
+ async revokeSession(sessionId) {
723
+ const res = await this.authRequest(`/v1/sdk/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: 'POST', includeAuth: true });
724
+ await this.checkResponse(res);
725
+ // Revoking your own session leaves a token the server has dropped. The
726
+ // SDK cannot tell from the response which one it was, and re-listing to
727
+ // find out would cost a request on every revoke — so it asks the only
728
+ // party that already knows: the next request. If it was this session,
729
+ // that request 401s and the normal refresh path clears it.
730
+ //
731
+ // A UI listing sessions knows which row is current from isCurrent, and
732
+ // should call logout() rather than revokeSession() for that one, which
733
+ // is both clearer to the user and one fewer round trip.
734
+ }
735
+ /**
736
+ * Sign out every OTHER session, keeping this one.
737
+ *
738
+ * What "sign out my other devices" means after a lost phone. Returns how
739
+ * many were ended, so an app can say so rather than guess.
740
+ */
741
+ async revokeAllOtherSessions() {
742
+ const res = await this.authRequest('/v1/sdk/auth/sessions/revoke-all', {
743
+ method: 'POST',
744
+ includeAuth: true,
745
+ });
746
+ await this.checkResponse(res);
747
+ const body = (await res.json().catch(() => ({})));
748
+ return body.revoked_count ?? 0;
749
+ }
750
+ /**
751
+ * What has happened to this account — sign-ins, failures, lockouts,
752
+ * password changes.
753
+ *
754
+ * For a "recent security activity" screen. The server sanitizes each
755
+ * event, so this carries what that event type is allowed to say and
756
+ * nothing more.
757
+ *
758
+ * @param limit up to 200; the server caps it there.
759
+ */
760
+ async auditLog(options) {
761
+ const params = new URLSearchParams();
762
+ if (options?.limit !== undefined)
763
+ params.set('limit', String(options.limit));
764
+ if (options?.offset !== undefined)
765
+ params.set('offset', String(options.offset));
766
+ const qs = params.toString();
767
+ const res = await this.authRequest(`/v1/sdk/auth/audit${qs ? `?${qs}` : ''}`, { method: 'GET', includeAuth: true });
768
+ await this.checkResponse(res);
769
+ const body = (await res.json());
770
+ return {
771
+ events: (body.events ?? []).map((e) => ({
772
+ id: String(e.id),
773
+ eventType: String(e.event_type),
774
+ occurredAt: String(e.occurred_at),
775
+ ip: e.ip,
776
+ userAgent: e.user_agent,
777
+ eventData: e.event_data ?? {},
778
+ })),
779
+ total: Number(body.total ?? 0),
780
+ limit: Number(body.limit ?? 0),
781
+ offset: Number(body.offset ?? 0),
782
+ };
783
+ }
586
784
  async unlock(token) {
587
785
  const res = await this.authRequest('/v1/sdk/auth/unlock', {
588
786
  method: 'POST',
@@ -1,3 +1,4 @@
1
+ import { KoolbaseAggregateRequest, KoolbaseAggregateResult } from './aggregate.js';
1
2
  import { KoolbaseConflict } from './conflict.js';
2
3
  import { PendingWrite } from './pending-write.js';
3
4
  import { KoolbaseConfig, KoolbaseRecord, QueryOptions, QueryResult, UpsertResult, BatchOp, BatchResult, KoolbaseVector, SemanticSearchResult, SearchMode } from './types.js';
@@ -127,6 +128,28 @@ export declare class KoolbaseDatabase {
127
128
  * moment matters. Snapshot, not a live handle; per-user.
128
129
  */
129
130
  pendingWrites(): Promise<PendingWrite[]>;
131
+ /**
132
+ * Count and total over a whole collection, with the read rule applied
133
+ * inside the query.
134
+ *
135
+ * Not a paged query you add up yourself: the server aggregates the entire
136
+ * authorized set. And never a bare number — collections are schemaless, so
137
+ * a sum that quietly skipped three malformed rows would be a wrong number
138
+ * that looks right. Check `accounting` for what contributed.
139
+ *
140
+ * ```ts
141
+ * const r = await db.aggregate({
142
+ * collection: 'orders',
143
+ * groupBy: { field: 'created_at', bucket: 'month', timezone: 'Africa/Accra' },
144
+ * measures: [{ aggregate: 'sum', field: 'total', as: 'revenue' }],
145
+ * });
146
+ * ```
147
+ *
148
+ * A calendar bucket requires a timezone and the types enforce it: midnight
149
+ * means nothing without one, and Accra and UTC disagree about which day a
150
+ * 23:30 sale belongs to.
151
+ */
152
+ aggregate(request: KoolbaseAggregateRequest): Promise<KoolbaseAggregateResult>;
130
153
  conflicts(): Promise<KoolbaseConflict[]>;
131
154
  /**
132
155
  * Resolves by id, reloading the stored conflict first.
@@ -421,6 +421,70 @@ export class KoolbaseDatabase {
421
421
  const { pending } = await readOfflineState(userId);
422
422
  return pending.map(toPendingWrite);
423
423
  }
424
+ /**
425
+ * Count and total over a whole collection, with the read rule applied
426
+ * inside the query.
427
+ *
428
+ * Not a paged query you add up yourself: the server aggregates the entire
429
+ * authorized set. And never a bare number — collections are schemaless, so
430
+ * a sum that quietly skipped three malformed rows would be a wrong number
431
+ * that looks right. Check `accounting` for what contributed.
432
+ *
433
+ * ```ts
434
+ * const r = await db.aggregate({
435
+ * collection: 'orders',
436
+ * groupBy: { field: 'created_at', bucket: 'month', timezone: 'Africa/Accra' },
437
+ * measures: [{ aggregate: 'sum', field: 'total', as: 'revenue' }],
438
+ * });
439
+ * ```
440
+ *
441
+ * A calendar bucket requires a timezone and the types enforce it: midnight
442
+ * means nothing without one, and Accra and UTC disagree about which day a
443
+ * 23:30 sale belongs to.
444
+ */
445
+ async aggregate(request) {
446
+ const raw = await this.request('POST', '/v1/sdk/db/aggregate', {
447
+ collection: request.collection,
448
+ ...(request.where?.length ? { where: request.where } : {}),
449
+ ...(request.groupBy
450
+ ? {
451
+ group_by: {
452
+ field: request.groupBy.field,
453
+ ...(request.groupBy.bucket
454
+ ? {
455
+ bucket: request.groupBy.bucket,
456
+ timezone: request.groupBy.timezone,
457
+ }
458
+ : {}),
459
+ },
460
+ }
461
+ : {}),
462
+ measures: request.measures.map((m) => ({
463
+ aggregate: m.aggregate,
464
+ ...(m.field ? { field: m.field } : {}),
465
+ as: m.as,
466
+ })),
467
+ });
468
+ const accounting = {};
469
+ for (const [name, a] of Object.entries(raw.accounting ?? {})) {
470
+ accounting[name] = {
471
+ counted: Number(a.counted ?? 0),
472
+ skipped: Number(a.skipped ?? 0),
473
+ skippedReason: a.skipped_reason,
474
+ };
475
+ }
476
+ return {
477
+ groups: (raw.groups ?? []).map((g) => ({
478
+ category: String(g.category ?? ''),
479
+ values: g.values ?? {},
480
+ })),
481
+ matched: Number(raw.matched ?? 0),
482
+ accounting,
483
+ // Explicitly boolean: a missing field must read as false, not
484
+ // undefined, or `if (!r.tooManyGroups)` passes on a refused request.
485
+ tooManyGroups: raw.too_many_groups === true,
486
+ };
487
+ }
424
488
  async conflicts() {
425
489
  const userId = this.requireUserId('the conflict list');
426
490
  const { conflicts } = await readOfflineState(userId);
@@ -49,7 +49,13 @@ export class DeviceMetadata {
49
49
  if (this.cached)
50
50
  return this.cached;
51
51
  const platform = getPlatform().info.os;
52
- const platformVersion = getPlatform().info.version;
52
+ // 'unknown' rather than empty, matching appVersion above. A browser
53
+ // outside the handful browserVersion() recognises — Brave, Opera,
54
+ // Samsung Internet, an in-app webview — reports nothing, and an empty
55
+ // header stored against a session is indistinguishable from an SDK that
56
+ // never reported itself at all. 'unknown' says the SDK spoke and could
57
+ // not identify the host, which is a different and more useful fact.
58
+ const platformVersion = getPlatform().info.version || 'unknown';
53
59
  const deviceLabel = await this.getOrCreateDeviceLabel();
54
60
  // No User-Agent: a browser forbids setting it, and the same header set
55
61
  // now goes out from both hosts. The platform and version below carry
@@ -1,5 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './errors.js';
3
+ export * from './aggregate.js';
3
4
  export * from './conflict.js';
4
5
  export * from './pending-write.js';
5
6
  export * from './function-errors.js';
package/dist/esm/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // code installs the platform package, never this one directly.
6
6
  export * from './types.js';
7
7
  export * from './errors.js';
8
+ export * from './aggregate.js';
8
9
  export * from './conflict.js';
9
10
  export * from './pending-write.js';
10
11
  export * from './function-errors.js';
@@ -88,6 +88,64 @@ export type SignUpResult = {
88
88
  user: KoolbaseUser;
89
89
  session: null;
90
90
  };
91
+ /**
92
+ * One active session — somewhere the user is signed in.
93
+ *
94
+ * Token hashes are never included; the server excludes them deliberately.
95
+ */
96
+ export interface KoolbaseSessionInfo {
97
+ id: string;
98
+ /** Where it signed in from, when the server captured it. */
99
+ ip?: string;
100
+ /** What signed in. A browser sets this; native SDKs send their own. */
101
+ userAgent?: string;
102
+ /** A per-install label, where the SDK supplied one. */
103
+ deviceLabel?: string;
104
+ createdAt: string;
105
+ expiresAt: string;
106
+ /**
107
+ * True for the session making the request.
108
+ *
109
+ * A device list needs this: without it a user cannot tell which row is the
110
+ * phone in their hand, and "sign out everywhere else" has nothing to
111
+ * exclude.
112
+ */
113
+ isCurrent: boolean;
114
+ }
115
+ /**
116
+ * One thing that happened to this account.
117
+ *
118
+ * The server sanitizes these: `eventData` carries only the fields allowed
119
+ * for that event type, so a lockout can say which address was attempted
120
+ * while a successful login carries nothing extra.
121
+ */
122
+ export interface KoolbaseAuditEvent {
123
+ id: string;
124
+ /**
125
+ * What happened. One of: auth.login.success, auth.login.failed,
126
+ * auth.account.locked, auth.account.unlocked, auth.session.created,
127
+ * auth.session.refreshed, auth.session.revoked, auth.password.changed,
128
+ * auth.user.verified.
129
+ *
130
+ * A string rather than a union: the server may add one, and an app that
131
+ * stops compiling because it did would be worse than one that shows an
132
+ * event it does not recognise.
133
+ */
134
+ eventType: string;
135
+ occurredAt: string;
136
+ ip?: string;
137
+ userAgent?: string;
138
+ /** Whatever that event type is allowed to carry. Often empty. */
139
+ eventData: Record<string, unknown>;
140
+ }
141
+ /** A page of audit events, and how many there are in total. */
142
+ export interface KoolbaseAuditPage {
143
+ events: KoolbaseAuditEvent[];
144
+ /** Across all pages, so an app knows whether to offer "show more". */
145
+ total: number;
146
+ limit: number;
147
+ offset: number;
148
+ }
91
149
  export interface ResendVerificationResult {
92
150
  alreadyVerified: boolean;
93
151
  /** When the link in the email stops working. Null when nothing was sent. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "11.4.0",
3
+ "version": "12.0.0",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",